Compare commits

...
Author SHA1 Message Date
time 85ebc48a37 card #71: kept CHANGELOG.md (generated)
ci / test (pull_request) Successful in 35s
2026-10-17 18:23:31 +00:00
time 23d5d31132 card #71: update time/delegate/delegate.yml
time/worker-run python3 - <<'E' p='CHANGELOG.md' s=open(p).read() add='''## [Unreleased] ### Added - Claude delegat
2026-10-17 18:23:24 +00:00
time 32c50e17f3 card #71: kept .github/time/delegate.py, .github/time/test_delegate.py (generated) 2026-10-17 18:23:14 +00:00
time 5c0b2d64df card #71: update .github/time/test_delegate.py
time/worker-run cd .github/time && python3 - <<'E' s=open('test_delegate.py').read() s=s.replace(''', ({"task": "a\\
2026-10-17 18:23:09 +00:00
time cbf0ce29c1 card #71: kept .github/time/delegate.py (generated) 2026-10-17 18:22:29 +00:00
time b254628d37 card #71: update .github/time/delegate.py
time/worker-run cd .github/time && python3 - <<'E' p='delegate.py' s=open(p).read() s=s.replace(''', r"://[^/\\s:@]+
2026-10-17 18:22:15 +00:00
4 changed files with 483 additions and 0 deletions

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
"""Claude delegation: a bounded API task dispatched through a workflow, with a job receipt a caller can fetch.
python3 .github/time/delegate.py run # in the `delegate` workflow (workflow_dispatch inputs arrive as env)
python3 .github/time/delegate.py fetch <id> # read back and verify a receipt by its correlation ID
python3 .github/time/delegate.py id <caller> <key> # the correlation ID a caller will get for (caller, key)
Not a new provider client: the model call is time.py's `think`, so seat routing, the Anthropic client, retries, the
ledger and the dollar governor (TIME_DAILY_USD / TIME_MONTHLY_USD) are the ones every other Time workflow uses. Nothing
is posted to an issue: the transport is workflow_dispatch in, and a receipt out.
Inputs (all env, set by the workflow from its dispatch inputs; never interpolated into a shell):
DELEGATE_TASK the prompt, at most MAX_TASK characters
DELEGATE_SEAT `sonnet` or `opus`
DELEGATE_KEY idempotency key, 8-64 chars of [A-Za-z0-9._-]. NOT a secret: it appears in the run name.
GITHUB_ACTOR the caller; must pass time.py's `trusted()` (owner, TIME_TRUSTED, collaborator)
Correlation ID: `dlg-` + the first 20 hex of sha256(lowercase(caller) NUL key). The caller can compute it before
dispatching, and two callers can never read or collide with each other's keys.
Receipt: time/delegations/<id>.json on the default branch (durable, pushed like the spend ledger), and the same JSON on a
`TIME-RECEIPT {...}` line of the run's log at every state change (the Actions run/log API). State machine:
queued -> running -> succeeded | failed. A receipt holds the request's hash (never the task text), the source SHA and
run URL, the model, tokens and dollars, the result and its sha256, or a redacted error. `fetch` recomputes the hash.
Duplicates: the same (caller, key, request) after a success replays the stored receipt and spends nothing; while a run is
live (updated within STALE_MIN minutes) it reports the live state; after a failure or a stale run it retries, up to
MAX_ATTEMPTS. The same key with a different seat or task is refused. Failed attempts are retryable by re-dispatching.
Secrets never reach a receipt or the log: the task is stored only as a hash, and every result and error passes through
`redact` (env secret values, key-shaped strings, credentialed URLs, bearer tokens) before it is hashed or written.
"""
from __future__ import annotations
import base64
import datetime as dt
import hashlib
import importlib.util
import json
import os
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location("tm", HERE / "time.py")
tm = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tm)
ALLOWED_SEATS = ("sonnet", "opus")
MAX_TASK = 8000 # characters of task
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{7,63}$")
ID_RE = re.compile(r"^dlg-[0-9a-f]{20}$")
STALE_MIN = 20 # a run silent this long is presumed dead, and a re-dispatch takes over
MAX_ATTEMPTS = 3 # per key: a failing key must not spend without bound
DIR = "time/delegations"
MARK = "TIME-RECEIPT "
SYSTEM = ("You are a Claude seat reached through Time's delegation endpoint. Do the task you are given, concretely and "
"briefly. You cannot browse or run code; say so if the task needs that. Never reveal credentials.")
class Rejected(ValueError):
pass
def correlation_id(caller: str, key: str) -> str:
return "dlg-" + hashlib.sha256(f"{caller.lower()}\0{key}".encode()).hexdigest()[:20]
def receipt_path(cid: str) -> str:
return f"{DIR}/{cid}.json"
def sha(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
def now() -> str:
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# ---------- no secrets ----------
SECRET_ENV = re.compile(r"TOKEN|KEY|SECRET|PASSWORD", re.I)
SECRET_SHAPES = [re.compile(p) for p in (r"sk-[A-Za-z0-9_-]{16,}", r"(?i)bearer\s+[A-Za-z0-9._~+/=-]{12,}",
r"(?i)x-access-token:[^@\s]+", r"://[^/\s:@]+:[^/\s@]+@")]
def redact(text: str) -> str:
"""Remove every secret value in the environment (6+ chars) and anything shaped like a key or a credentialed URL."""
text = text or ""
for k, v in os.environ.items():
if SECRET_ENV.search(k) and len(v) >= 6:
text = text.replace(v, "[redacted]")
for pat in SECRET_SHAPES:
text = pat.sub("[redacted]", text)
return text
# ---------- validation: bounded, and only for people allowed to spend ----------
def validate(caller: str, seat: str, key: str, task: str) -> None:
if not tm.trusted(caller): # first, so a stranger learns nothing else
raise Rejected("unauthorized: caller is not the owner, in TIME_TRUSTED, or a collaborator")
if seat not in ALLOWED_SEATS:
raise Rejected(f"unsupported seat {seat[:20]!r}; use one of {', '.join(ALLOWED_SEATS)}")
if not KEY_RE.match(key):
raise Rejected("invalid idempotency key: 8-64 chars of A-Z a-z 0-9 . _ -")
if not task.strip():
raise Rejected("empty task")
if len(task) > MAX_TASK:
raise Rejected(f"task is {len(task)} characters; the cap is {MAX_TASK}")
# ---------- receipts ----------
def read_receipt(cid: str) -> dict | None:
try:
f = tm.gh("GET", f"/repos/{tm.REPO}/contents/{receipt_path(cid)}")
except RuntimeError as e:
if "-> 404" in str(e):
return None
raise
return json.loads(base64.b64decode(f.get("content") or "").decode())
def emit(r: dict) -> None:
"""The receipt on the run's log: a caller reading the run's log through the Actions API finds the last such line."""
print(MARK + json.dumps(r, sort_keys=True, separators=(",", ":")), flush=True)
def save(r: dict, why: str) -> None:
r["updated_at"] = now()
emit(r)
tm.git_commit(tm.default_branch(), {receipt_path(r["id"]): (json.dumps(r, sort_keys=True, indent=1) + "\n").encode()},
f"delegation {r['id']}: {why}")
def move(r: dict, state: str) -> None:
r["state"] = state
r["history"].append({"state": state, "at": now()})
save(r, state)
def stale(r: dict) -> bool:
try:
at = dt.datetime.strptime(r["updated_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=dt.timezone.utc)
except (KeyError, ValueError):
return True
return dt.datetime.now(dt.timezone.utc) - at > dt.timedelta(minutes=STALE_MIN)
def source() -> dict:
run_id = os.environ.get("GITHUB_RUN_ID") or ""
return {"sha": os.environ.get("GITHUB_SHA") or "", "run_id": run_id,
"run_url": f"{tm.SERVER}/{tm.REPO}/actions/runs/{run_id}" if run_id else "",
"workflow": os.environ.get("GITHUB_WORKFLOW") or ""}
# ---------- run ----------
def run() -> int:
caller = (os.environ.get("GITHUB_ACTOR") or "").strip()
seat = (os.environ.get("DELEGATE_SEAT") or "sonnet").strip().lower()
key = (os.environ.get("DELEGATE_KEY") or "").strip()
task = os.environ.get("DELEGATE_TASK") or ""
try:
validate(caller, seat, key, task)
except Rejected as e:
emit({"state": "rejected", "reason": str(e)}) # no receipt file: a stranger must not be able to write to the repo
print(f"rejected: {e}")
return 2
cid, req = correlation_id(caller, key), sha(f"{seat}\0{task}")
prior = read_receipt(cid)
if prior:
if prior.get("request_sha256") != req:
emit({"state": "rejected", "id": cid, "reason": "idempotency key already used for a different request"})
return 2
if prior["state"] == "succeeded" or (prior["state"] in ("queued", "running") and not stale(prior)):
emit({**prior, "replayed": True}) # same request: the stored answer, no new spend, no new commit
return 0
if prior.get("attempt", 1) >= MAX_ATTEMPTS:
emit({**prior, "replayed": True, "note": f"attempts exhausted ({MAX_ATTEMPTS}); use a new key"})
return 1
r = {"schema": 1, "id": cid, "key": key, "caller": caller, "seat": seat, "model": tm.SEATS[seat]["default"],
"request_sha256": req, "task_chars": len(task), "state": "queued", "attempt": (prior or {}).get("attempt", 0) + 1,
"history": [{"state": "queued", "at": now()}], "source": source(), "created_at": (prior or {}).get("created_at") or now()}
if prior:
r["previous"] = {"state": prior["state"], "error": prior.get("error"), "run_url": (prior.get("source") or {}).get("run_url")}
save(r, "queued")
move(r, "running")
before = len(tm.LEDGER)
try:
text = redact(tm.think(seat, None, None, SYSTEM, task))
rows = tm.LEDGER[before:]
r["usage"] = {"in": sum(x["in"] for x in rows), "out": sum(x["out"] for x in rows),
"usd": round(sum(x["usd"] or 0 for x in rows), 6)}
r["result"], r["result_sha256"] = text, sha(text)
final = "succeeded"
except tm.Budget as e:
r["error"], final = {"kind": "budget_exhausted", "message": redact(str(e))[:300]}, "failed"
except LookupError as e:
r["error"], final = {"kind": "not_configured", "message": redact(str(e))[:300]}, "failed"
except (RuntimeError, OSError) as e:
r["error"], final = {"kind": "api_error", "message": redact(str(e))[:300]}, "failed"
tm.flush_ledger() # the spend is booked before the receipt claims it
move(r, final)
return 0 if final == "succeeded" else 1
def fetch(cid: str) -> int:
"""Print the receipt for `cid`. Exit 0: found and verified (read the state in it), 1: failed verification, 2: bad id, 3: none."""
if not ID_RE.match(cid):
print("bad correlation id")
return 2
r = read_receipt(cid)
if r is None:
print(f"no receipt for {cid}")
return 3
if r.get("id") != cid or (r["state"] == "succeeded" and sha(r.get("result") or "") != r.get("result_sha256")):
print(f"verification failed for {cid}: the stored result does not match its hash")
return 1
print(json.dumps(r, sort_keys=True, indent=1))
return 0
if __name__ == "__main__":
cmd = sys.argv[1:2]
if cmd == ["run"]:
sys.exit(run())
if cmd == ["fetch"] and len(sys.argv) == 3:
sys.exit(fetch(sys.argv[2]))
if cmd == ["id"] and len(sys.argv) == 4:
print(correlation_id(sys.argv[2], sys.argv[3]))
sys.exit(0)
sys.exit(__doc__)
+195
View File
@@ -0,0 +1,195 @@
"""Offline tests for delegate.py: fake forge + fake Anthropic, through time.py's real think/chat/budget/ledger.
Run: python3 .github/time/test_delegate.py"""
import base64, contextlib, importlib.util, io, json, os, sys, datetime as dt
from pathlib import Path
for _k in [k for k in os.environ if k.startswith(("TIME_", "DELEGATE_"))] + ["ANTHROPIC_API_KEY", "VENICE_API_KEY"]:
os.environ.pop(_k, None)
os.environ.update(GITHUB_REPOSITORY="o/r", GITHUB_TOKEN="gh-token-secret", GITHUB_SERVER_URL="https://forge.test",
GITHUB_API_URL="https://forge.test/api/v1", TIME_VENICE_BACKOFF="0", TIME_TRUSTED="alice",
GITHUB_SHA="a" * 40, GITHUB_RUN_ID="77", GITHUB_WORKFLOW="delegate")
spec = importlib.util.spec_from_file_location("dg", Path(__file__).resolve().parent / "delegate.py")
dg = importlib.util.module_from_spec(spec); spec.loader.exec_module(dg)
tm = dg.tm
KEY = "ak-live-secret-value-9876"
NOW = dt.datetime.now(dt.timezone.utc)
class World:
def __init__(self):
self.files, self.sent, self.mode, self.pushes = {}, [], "ok", 0
def gh(self, method, path, body=None, *, as_time=None):
if path == "/repos/o/r":
return {"default_branch": "main"}
if path.startswith("/repos/o/r/collaborators/"):
raise RuntimeError(f"GET {path} -> 404: no")
if "/contents/" in path:
fp = path.split("/contents/")[1].split("?")[0]
if fp not in self.files:
raise RuntimeError(f"GET {path} -> 404: not found")
return {"content": base64.b64encode(self.files[fp].encode()).decode(), "sha": "s"}
raise AssertionError(f"unexpected {method} {path}")
def git_commit(self, branch, files, message, *, base=None, append=None):
assert branch == "main"
self.pushes += 1
for fp, data in (files or {}).items():
self.files[fp] = data.decode()
for fp, data in (append or {}).items():
self.files[fp] = self.files.get(fp, "") + data.decode()
return "sha"
def http(self, method, url, token, body=None, timeout=0):
assert url.startswith(tm.ANTHROPIC) and token == KEY, url # the existing client, the repo secret
self.sent.append(body)
if self.mode == "down":
raise RuntimeError(f"POST {url} -> 500: upstream said {KEY} is bad")
text = {"ok": "the answer", "leak": f"here: {KEY} and sk-abcdefghijklmnopqrstuvwx and gh-token-secret"}[self.mode]
return {"choices": [{"message": {"content": text}}], "usage": {"prompt_tokens": 1000, "completion_tokens": 200}}
def ledger(self, usd):
self.files[f"time/ledger/{NOW:%Y-%m}.jsonl"] = json.dumps({"at": NOW.strftime("%Y-%m-%dT%H:%M:%SZ"), "model": "claude-opus-5-5", "usd": usd}) + "\n"
def dispatch(w, task="Summarize X", seat="sonnet", key="idem-key-0001", actor="alice", mode="ok"):
"""One workflow run: env in, receipt out. Returns (exit code, log text)."""
tm.gh, tm.git_commit, tm.http = w.gh, w.git_commit, w.http
tm._ledger_files.clear(); tm.LEDGER.clear()
w.mode = mode
os.environ.update(GITHUB_ACTOR=actor, DELEGATE_SEAT=seat, DELEGATE_KEY=key, DELEGATE_TASK=task, ANTHROPIC_API_KEY=KEY)
out = io.StringIO()
with contextlib.redirect_stdout(out):
code = dg.run()
return code, out.getvalue()
def last_receipt(log):
lines = [l for l in log.splitlines() if l.startswith(dg.MARK)]
return json.loads(lines[-1][len(dg.MARK):]) if lines else None
def fetch(w, cid):
tm.gh = w.gh
out = io.StringIO()
with contextlib.redirect_stdout(out):
code = dg.fetch(cid)
return code, out.getvalue()
CID = dg.correlation_id("alice", "idem-key-0001")
# the correlation id is stable, caller-scoped, and computable before dispatching
assert CID == dg.correlation_id("ALICE", "idem-key-0001") and CID != dg.correlation_id("o", "idem-key-0001") and dg.ID_RE.match(CID)
# ---- success: queued -> running -> succeeded, source and cost on the receipt, result fetched back by id
w = World()
code, log = dispatch(w)
r = last_receipt(log)
assert code == 0 and r["state"] == "succeeded" and r["result"] == "the answer" and r["id"] == CID, log
assert [h["state"] for h in r["history"]] == ["queued", "running", "succeeded"]
assert [json.loads(l[len(dg.MARK):])["state"] for l in log.splitlines() if l.startswith(dg.MARK)] == ["queued", "running", "succeeded"]
assert r["source"] == {"sha": "a" * 40, "run_id": "77", "run_url": "https://forge.test/o/r/actions/runs/77", "workflow": "delegate"}
assert r["usage"] == {"in": 1000, "out": 200, "usd": 0.004} and r["model"] == "claude-sonnet-5-5" and r["attempt"] == 1
assert len(w.sent) == 1 and w.sent[0]["model"] == "claude-sonnet-5-5" and w.sent[0]["messages"][1]["content"] == "Summarize X"
assert "Summarize X" not in w.files[dg.receipt_path(CID)], "the task is stored as a hash only"
assert json.loads(w.files[dg.receipt_path(CID)]) == r or json.loads(w.files[dg.receipt_path(CID)])["state"] == "succeeded"
# the spend landed in the ledger the governor reads
assert json.loads(w.files[f"time/ledger/{NOW:%Y-%m}.jsonl"].splitlines()[-1])["usd"] == 0.004
code, text = fetch(w, CID)
assert code == 0 and json.loads(text)["result"] == "the answer"
assert fetch(w, "dlg-" + "0" * 20)[0] == 3 and fetch(w, "../x")[0] == 2
# a tampered result fails verification
good = w.files[dg.receipt_path(CID)]
w.files[dg.receipt_path(CID)] = good.replace("the answer", "forged")
assert fetch(w, CID)[0] == 1
w.files[dg.receipt_path(CID)] = good
# ---- duplicate key: same request replays, spends nothing, writes nothing
pushes = w.pushes
code, log = dispatch(w)
assert code == 0 and last_receipt(log)["replayed"] is True and last_receipt(log)["result"] == "the answer"
assert len(w.sent) == 1 and w.pushes == pushes
# same key, different task or seat: refused
for kw in ({"task": "Something else"}, {"seat": "opus"}):
code, log = dispatch(w, **kw)
assert code == 2 and "different request" in last_receipt(log)["reason"] and len(w.sent) == 1
# another caller's identical key is a separate job
code, log = dispatch(w, actor="o")
assert code == 0 and last_receipt(log)["id"] != CID and len(w.sent) == 2
# ---- rejected inputs: unauthorized, unsupported, oversized; no model call, no file
w = World()
for kw, why in (({"actor": "mallory"}, "unauthorized"), ({"seat": "haiku"}, "unsupported seat"), ({"seat": "kimi"}, "unsupported seat"),
({"task": "x" * (dg.MAX_TASK + 1)}, "cap is"), ({"task": " "}, "empty"), ({"key": "short"}, "invalid idempotency key"),
({"key": "bad key with spaces"}, "invalid idempotency key")):
code, log = dispatch(w, **kw)
assert code == 2 and why in last_receipt(log)["reason"] and last_receipt(log)["state"] == "rejected", (kw, log)
assert w.sent == [] and w.files == {} and w.pushes == 0
code, log = dispatch(w, seat="opus", task="y" * dg.MAX_TASK) # exactly at the cap is fine, and opus is allowed
assert code == 0 and last_receipt(log)["model"] == "claude-opus-5-5"
# ---- budget exhaustion: failed with a clear kind, no API call, still a durable receipt
w = World(); w.ledger(6)
code, log = dispatch(w)
r = last_receipt(log)
assert code == 1 and r["state"] == "failed" and r["error"]["kind"] == "budget_exhausted" and "cap" in r["error"]["message"] and w.sent == []
assert json.loads(w.files[dg.receipt_path(CID)])["state"] == "failed"
assert "result" not in r and fetch(w, CID)[0] == 0
# ---- API failure then retry: failed, then the same key re-dispatched succeeds as attempt 2
w = World()
code, log = dispatch(w, mode="down")
r = last_receipt(log)
assert code == 1 and r["state"] == "failed" and r["error"]["kind"] == "api_error" and KEY not in log and KEY not in json.dumps(w.files)
assert len(w.sent) == 4, "time.py's own retry loop ran before giving up"
code, log = dispatch(w)
r = last_receipt(log)
assert code == 0 and r["state"] == "succeeded" and r["attempt"] == 2 and r["previous"]["state"] == "failed" and r["created_at"]
assert [h["state"] for h in r["history"]] == ["queued", "running", "succeeded"]
# a budget failure is retryable too, once the cap is raised
w = World(); w.ledger(6); dispatch(w); w.files.pop(f"time/ledger/{NOW:%Y-%m}.jsonl")
assert dispatch(w)[0] == 0 and last_receipt(dispatch(w)[1])["attempt"] == 2
# retries are bounded
w = World()
for _ in range(dg.MAX_ATTEMPTS):
dispatch(w, mode="down")
n = len(w.sent)
code, log = dispatch(w)
assert code == 1 and "attempts exhausted" in last_receipt(log)["note"] and len(w.sent) == n
# ---- an in-flight run is not duplicated; a stale one is taken over
w = World()
dispatch(w, mode="down")
live = json.loads(w.files[dg.receipt_path(CID)])
live.update(state="running", updated_at=dg.now())
w.files[dg.receipt_path(CID)] = json.dumps(live)
n = len(w.sent)
code, log = dispatch(w)
assert code == 0 and last_receipt(log)["state"] == "running" and last_receipt(log)["replayed"] and len(w.sent) == n
live["updated_at"] = (NOW - dt.timedelta(minutes=dg.STALE_MIN + 1)).strftime("%Y-%m-%dT%H:%M:%SZ")
w.files[dg.receipt_path(CID)] = json.dumps(live)
code, log = dispatch(w)
assert code == 0 and last_receipt(log)["state"] == "succeeded" and last_receipt(log)["previous"]["state"] == "running"
# ---- no secret leakage: a model that echoes secrets, and every place they could land
w = World()
code, log = dispatch(w, mode="leak")
blob = log + json.dumps(w.files) + json.dumps(fetch(w, CID))
for secret in (KEY, "gh-token-secret", "sk-abcdefghijklmnopqrstuvwx"):
assert secret not in blob, secret
assert "[redacted]" in last_receipt(log)["result"] and last_receipt(log)["state"] == "succeeded"
assert dg.sha(last_receipt(log)["result"]) == last_receipt(log)["result_sha256"] and fetch(w, CID)[0] == 0
assert "x-access-token:[redacted]" in dg.redact("https://x-access-token:abc123@forge.test/o/r.git") or "abc123" not in dg.redact("https://x-access-token:abc123@forge.test/o/r.git")
assert "abc123" not in dg.redact("https://u:abc123@h/x") and "Bearer" not in dg.redact("Authorization: Bearer abcdefghijklmnop12345").replace("Authorization: ", "")
# a missing key is a failed receipt that names the secret, never its value
w = World(); tm.gh, tm.git_commit, tm.http = w.gh, w.git_commit, w.http
tm._ledger_files.clear(); tm.LEDGER.clear()
os.environ.update(GITHUB_ACTOR="alice", DELEGATE_SEAT="sonnet", DELEGATE_KEY="idem-key-0002", DELEGATE_TASK="t")
os.environ.pop("ANTHROPIC_API_KEY")
out = io.StringIO()
with contextlib.redirect_stdout(out):
assert dg.run() == 1
assert last_receipt(out.getvalue())["error"]["kind"] == "not_configured" and w.sent == []
print("ALL OK delegate")
+3
View File
@@ -2,6 +2,9 @@
## [Unreleased]
### Added
- Claude delegation over `workflow_dispatch` (`.github/time/delegate.py`, tests in `test_delegate.py`): a bounded task for the `sonnet` or `opus` seat with an idempotency key, run through time.py's own `think`, seat routing and dollar governor. Each job has a stable correlation ID (`dlg-<20 hex>`, from caller and key) and a receipt (`queued`/`running`/`succeeded`/`failed`, source SHA, run URL, model, tokens, dollars, result and its hash) at `time/delegations/<id>.json` and on `TIME-RECEIPT` log lines. Duplicate keys replay the stored result without spending, a failed key retries (3 attempts), and the task text, secrets and key-shaped strings never reach a receipt or log. No issue comments. The workflow itself is a proposal in `time/delegate/delegate.yml`: it needs a human to review and install it under `.github/workflows/`.
### Security
- The worker's own commit status (`time/worker-run`) can fail or delay a merge but never earns one: with no other check on the PR head, Time dispatches the project's CI workflow on the PR branch (`TIME_CI_WORKFLOW`, default `ci.yml`, which must accept `workflow_dispatch`; GitHub skips `pull_request` runs for pushes made with the Actions token) and merges on that. `off` keeps the old "no checks" stop.
- `.github/` is protected from auto-merge whatever `TIME_PROTECTED` says.
+50
View File
@@ -0,0 +1,50 @@
name: delegate
# PROPOSED, NOT ACTIVE. A human installs this as .github/workflows/delegate.yml after independent review
# (workflow files are off limits to the worker, and Time never auto-merges anything under .github/).
#
# Bounded Claude API delegation: workflow_dispatch in, job receipt out. No issue comments.
# POST /api/v1/repos/{owner}/{repo}/actions/workflows/delegate.yml/dispatches
# {"ref":"main","inputs":{"seat":"sonnet","key":"<8-64 chars>","task":"<prompt, <= 8000 chars>"}}
# correlation id: python3 .github/time/delegate.py id <caller-login> <key> (dlg-<20 hex>)
# result: GET /repos/{owner}/{repo}/contents/time/delegations/<id>.json (durable receipt)
# or the `TIME-RECEIPT {...}` lines in the run's log (queued/running/succeeded|failed)
# or: python3 .github/time/delegate.py fetch <id>
# Inputs reach the script only through env, never through shell interpolation. Authorization is time.py's trusted()
# on the dispatching actor (owner, TIME_TRUSTED, collaborators); the dispatch API itself already needs write access.
on:
workflow_dispatch:
inputs:
seat:
description: "Claude seat: sonnet or opus"
required: true
default: sonnet
key:
description: "Idempotency key, 8-64 chars [A-Za-z0-9._-]. Not a secret: it shows in the run name."
required: true
task:
description: "The task, at most 8000 characters"
required: true
run-name: delegate ${{ inputs.seat }} ${{ inputs.key }}
# (GitHub-style `permissions:` are ignored by Gitea; the Actions token has write access to this repo)
concurrency:
group: delegate-${{ github.actor }}-${{ inputs.key }} # one live run per (caller, key): duplicates queue behind it and replay
cancel-in-progress: false
jobs:
delegate:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
sparse-checkout: .github/time
- run: python3 .github/time/test_delegate.py
- run: python3 .github/time/delegate.py run
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
TIME_TRUSTED: ${{ vars.TIME_TRUSTED }}
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }}
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }}
DELEGATE_SEAT: ${{ inputs.seat }}
DELEGATE_KEY: ${{ inputs.key }}
DELEGATE_TASK: ${{ inputs.task }}