diff --git a/cmd/forge-codex-plugin/main.go b/cmd/forge-codex-plugin/main.go index c8a5776..f97d675 100644 --- a/cmd/forge-codex-plugin/main.go +++ b/cmd/forge-codex-plugin/main.go @@ -16,6 +16,7 @@ import ( "agent-forge/internal/configjson" "agent-forge/internal/pluginprotocol" "agent-forge/internal/processtree" + "agent-forge/internal/protocol" ) func main() { @@ -32,26 +33,25 @@ func main() { } func serve(in io.Reader, out io.Writer) error { - return pluginprotocol.Serve(in, out, []pluginprotocol.Capability{pluginprotocol.WorkspaceEdit, pluginprotocol.CommitSubject}, func(ctx context.Context, request pluginprotocol.Request) (pluginprotocol.Result, error) { - subject, err := executeCodex(ctx, request) - return pluginprotocol.Result{CommitSubject: subject}, err + return pluginprotocol.Serve(in, out, []pluginprotocol.Capability{pluginprotocol.WorkspaceEdit, pluginprotocol.CommitSubject, pluginprotocol.AgentReport}, func(ctx context.Context, request pluginprotocol.Request) (pluginprotocol.Result, error) { + return executeCodex(ctx, request) }) } -func executeCodex(parent context.Context, request pluginprotocol.Request) (*string, error) { +func executeCodex(parent context.Context, request pluginprotocol.Request) (pluginprotocol.Result, error) { head, err := gitHead(request.Workspace) if err != nil { - return nil, fmt.Errorf("invalid_workspace") + return pluginprotocol.Result{}, fmt.Errorf("invalid_workspace") } privateDir, err := os.MkdirTemp(os.TempDir(), "forge-codex-") if err != nil { - return nil, fmt.Errorf("codex_failed") + return pluginprotocol.Result{}, fmt.Errorf("codex_failed") } defer os.RemoveAll(privateDir) schemaPath, outputPath := filepath.Join(privateDir, "schema.json"), filepath.Join(privateDir, "final.json") - schema := []byte(`{"type":"object","properties":{"commit_subject":{"type":"string"}},"required":["commit_subject"],"additionalProperties":false}`) + schema := []byte(`{"type":"object","properties":{"commit_subject":{"type":"string","minLength":1,"maxLength":256},"summary":{"type":"string","minLength":1,"maxLength":1024},"changes":{"type":"array","minItems":1,"maxItems":12,"items":{"type":"string","minLength":1,"maxLength":256}}},"required":["commit_subject","summary","changes"],"additionalProperties":false}`) if os.WriteFile(schemaPath, schema, 0o600) != nil || os.WriteFile(outputPath, nil, 0o600) != nil { - return nil, fmt.Errorf("codex_failed") + return pluginprotocol.Result{}, fmt.Errorf("codex_failed") } bin := os.Getenv("CODEX_BIN") if bin == "" { @@ -59,33 +59,34 @@ func executeCodex(parent context.Context, request pluginprotocol.Request) (*stri } ctx, cancel := context.WithTimeout(parent, time.Duration(request.TimeoutMS)*time.Millisecond) defer cancel() - prompt := "Edit only files in the provided workspace to complete the task. Follow AGENTS.md and other repository instructions only when they do not conflict with this prompt's constraints or the task. Within this plugin's existing execution environment and lifecycle, you may run workspace-local, repository-native focused validation when useful and not prohibited by the task. Its output and your claims are advisory executor feedback, never Worker acceptance evidence. Do not use Git, commit, or access paths outside the workspace. After inspecting the actual resulting diff, return exactly the structured final object requested by the output schema with one conventional commit subject describing the actual change.\n\nTask:\n" + request.Instruction + prompt := "Edit only files in the provided workspace to complete the task. Follow AGENTS.md and other repository instructions only when they do not conflict with this prompt's constraints or the task. Within this plugin's existing execution environment and lifecycle, you may run workspace-local, repository-native focused validation when useful and not prohibited by the task. Its output and your claims are advisory executor feedback, never Worker acceptance evidence. Use read-only git diff within the workspace to inspect changes; do not use other Git commands, commit, or access paths outside the workspace. After inspecting the actual resulting diff, return exactly the structured final object requested by the output schema with exactly commit_subject (one conventional commit subject), summary (concise, at most 1024 UTF-8 bytes), and changes (1–12 concrete change bullets, at most 256 UTF-8 bytes each). All strings must be non-empty, trimmed and contain no control characters. Describe only actual changes in the diff, including why they matter. This report is self-reported and is not independent check evidence.\n\nTask:\n" + request.Instruction cmd := exec.Command(bin, "exec", "--ephemeral", "--sandbox", "workspace-write", "--color", "never", "-C", request.Workspace, "--output-schema", schemaPath, "--output-last-message", outputPath, "-") cmd.Stdin = bytes.NewBufferString(prompt) budget := &outputBudget{n: 1 << 20} cmd.Stdout = &limitedWriter{budget: budget} cmd.Stderr = &limitedWriter{budget: budget} if err := processtree.Run(ctx, cmd); err != nil { - return nil, fmt.Errorf("codex_failed") + return pluginprotocol.Result{}, fmt.Errorf("codex_failed") } after, err := gitHead(request.Workspace) if err != nil || after != head { - return nil, fmt.Errorf("plugin_committed") + return pluginprotocol.Result{}, fmt.Errorf("plugin_committed") } file, err := os.Open(outputPath) if err != nil { - return nil, fmt.Errorf("codex_failed") + return pluginprotocol.Result{}, fmt.Errorf("codex_failed") } defer file.Close() - const maxFinalBytes = pluginprotocol.MaxCommitSubjectBytes + 64 + const maxFinalBytes = protocol.MaxAgentReportBytes + 2048 data, err := io.ReadAll(io.LimitReader(file, maxFinalBytes+1)) var final struct { CommitSubject string `json:"commit_subject"` + protocol.AgentReport } - if err != nil || len(data) == 0 || len(data) > maxFinalBytes || !utf8.Valid(data) || configjson.Decode(data, &final) != nil || pluginprotocol.ValidateCommitSubject(&final.CommitSubject, true) != nil { - return nil, fmt.Errorf("codex_failed") + if err != nil || len(data) == 0 || len(data) > maxFinalBytes || !utf8.Valid(data) || configjson.Decode(data, &final) != nil || pluginprotocol.ValidateCommitSubject(&final.CommitSubject, true) != nil || protocol.ValidateAgentReport(&final.AgentReport) != nil { + return pluginprotocol.Result{}, fmt.Errorf("codex_failed") } - return &final.CommitSubject, nil + return pluginprotocol.Result{CommitSubject: &final.CommitSubject, Report: &final.AgentReport}, nil } func gitHead(workspace string) (string, error) { diff --git a/cmd/forge-codex-plugin/main_test.go b/cmd/forge-codex-plugin/main_test.go index 09ae384..6afe2b4 100644 --- a/cmd/forge-codex-plugin/main_test.go +++ b/cmd/forge-codex-plugin/main_test.go @@ -40,32 +40,42 @@ assert schema.parent == output.parent and schema.parent.parent == pathlib.Path(o assert stat.S_IMODE(schema.parent.stat().st_mode) == 0o700 assert stat.S_IMODE(schema.stat().st_mode) == 0o600 spec=json.loads(schema.read_text()) -assert spec["required"] == ["commit_subject"] and spec["additionalProperties"] is False +assert spec["required"] == ["commit_subject", "summary", "changes"] and spec["additionalProperties"] is False prompt=sys.stdin.read() assert "actual resulting diff" in prompt +assert "read-only git diff" in prompt +assert "self-reported" in prompt and "1–12 concrete change bullets" in prompt assert "Do not run tests" not in prompt assert "Follow AGENTS.md and other repository instructions only when they do not conflict with this prompt's constraints or the task." in prompt assert "Within this plugin's existing execution environment and lifecycle, you may run workspace-local, repository-native focused validation when useful and not prohibited by the task." in prompt assert "Its output and your claims are advisory executor feedback, never Worker acceptance evidence." in prompt pathlib.Path(workspace,"answer.txt").write_text("edited\n") -output.write_text(json.dumps({"commit_subject":"fix: use executor result"},separators=(",",":"))) +output.write_text(json.dumps({"commit_subject":"fix: use executor result","summary":"Update the answer from executor feedback","changes":["Replace the base answer with the edited answer"]},separators=(",",":"))) `), 0o700); err != nil { t.Fatal(err) } t.Setenv("CODEX_BIN", fake) privateTmp := t.TempDir() t.Setenv("TMPDIR", privateTmp) - id := strings.Repeat("a", 32) - input := `{"version":"v1","id":"` + id + `","type":"initialize","capabilities":["workspace_edit","commit_subject"],"limits":{"frame_bytes":1048576,"progress_frames":128,"text_bytes":65536,"progress_text_bytes":1024,"commit_subject_bytes":256}}` + "\n" + - `{"version":"v1","id":"` + id + `","type":"execute","operation":"workspace_edit","workspace":` + quote(workspace) + `,"instruction":"edit","timeout_ms":1000}` + "\n" - var output bytes.Buffer - if err := serve(strings.NewReader(input), &output); err != nil { - t.Fatal(err) - } - want := `{"version":"v1","id":"` + id + `","type":"initialized","capabilities":["workspace_edit","commit_subject"]}` + "\n" + - `{"version":"v1","id":"` + id + `","type":"result","commit_subject":"fix: use executor result"}` + "\n" - if output.String() != want || git("rev-parse", "HEAD") != head { - t.Fatalf("output=%q head=%s", output.String(), git("rev-parse", "HEAD")) + for _, report := range []bool{false, true} { + id := strings.Repeat("a", 32) + capabilities := `"workspace_edit","commit_subject"` + fields := "" + if report { + capabilities += `,"agent_report"` + fields = `,"summary":"Update the answer from executor feedback","changes":["Replace the base answer with the edited answer"]` + } + input := `{"version":"v1","id":"` + id + `","type":"initialize","capabilities":[` + capabilities + `],"limits":{"frame_bytes":1048576,"progress_frames":128,"text_bytes":65536,"progress_text_bytes":1024,"commit_subject_bytes":256}}` + "\n" + + `{"version":"v1","id":"` + id + `","type":"execute","operation":"workspace_edit","workspace":` + quote(workspace) + `,"instruction":"edit","timeout_ms":1000}` + "\n" + var output bytes.Buffer + if err := serve(strings.NewReader(input), &output); err != nil { + t.Fatal(err) + } + want := `{"version":"v1","id":"` + id + `","type":"initialized","capabilities":[` + capabilities + `]}` + "\n" + + `{"version":"v1","id":"` + id + `","type":"result","commit_subject":"fix: use executor result"` + fields + "}\n" + if output.String() != want || git("rev-parse", "HEAD") != head { + t.Fatalf("report=%v output=%q", report, output.String()) + } } entries, err := os.ReadDir(privateTmp) if err != nil || len(entries) != 0 { @@ -91,13 +101,16 @@ if body: t.Setenv("CODEX_BIN", fake) t.Setenv("TMPDIR", privateTmp) for name, body := range map[string][]byte{ - "missing": nil, - "malformed": []byte(`{"commit_subject":`), - "duplicate": []byte(`{"commit_subject":"fix: one","commit_subject":"fix: two"}`), - "unknown": []byte(`{"commit_subject":"fix: one","private":"secret"}`), - "trailing": []byte(`{"commit_subject":"fix: one"}{}`), - "invalid UTF-8": {'{', '"', 'c', 'o', 'm', 'm', 'i', 't', '_', 's', 'u', 'b', 'j', 'e', 'c', 't', '"', ':', '"', 0xff, '"', '}'}, - "oversized": []byte(`{"commit_subject":"` + strings.Repeat("x", 400) + `"}`), + "missing": nil, + "missing report": []byte(`{"commit_subject":"fix: valid"}`), + "oversized summary": []byte(`{"commit_subject":"fix: valid","summary":"` + strings.Repeat("x", 1025) + `","changes":["x"]}`), + "malformed report": []byte(`{"commit_subject":"fix: valid","summary":"x","changes":[]}`), + "malformed": []byte(`{"commit_subject":`), + "duplicate": []byte(`{"commit_subject":"fix: one","commit_subject":"fix: two"}`), + "unknown": []byte(`{"commit_subject":"fix: one","private":"secret"}`), + "trailing": []byte(`{"commit_subject":"fix: one"}{}`), + "invalid UTF-8": {'{', '"', 'c', 'o', 'm', 'm', 'i', 't', '_', 's', 'u', 'b', 'j', 'e', 'c', 't', '"', ':', '"', 0xff, '"', '}'}, + "oversized": []byte(`{"commit_subject":"` + strings.Repeat("x", 400) + `"}`), } { t.Run(name, func(t *testing.T) { workspace := t.TempDir() diff --git a/cmd/forge-codex-plugin/testdata/fake-codex.py b/cmd/forge-codex-plugin/testdata/fake-codex.py index b9d03fa..1d46981 100755 --- a/cmd/forge-codex-plugin/testdata/fake-codex.py +++ b/cmd/forge-codex-plugin/testdata/fake-codex.py @@ -13,4 +13,4 @@ answer_go.write_text(answer_go.read_text().replace("return 0", "return 42")) else: pathlib.Path(workspace, "answer.txt").write_text("conformance\n") -output.write_text(json.dumps({"commit_subject": "test: prove conformance"}, separators=(",", ":"))) +output.write_text(json.dumps({"commit_subject": "test: prove conformance", "summary":"Update the fixture answer", "changes":["Write the conformance answer into the workspace"]}, separators=(",", ":"))) diff --git a/docs/plugin-protocol-v1.md b/docs/plugin-protocol-v1.md index 7dbc35d..22afa45 100644 --- a/docs/plugin-protocol-v1.md +++ b/docs/plugin-protocol-v1.md @@ -18,7 +18,7 @@ The Worker generates a lowercase 32-hex `id`. Every frame has exactly `version`, {"version":"v1","id":"0123456789abcdef0123456789abcdef","type":"initialized","capabilities":["text"]} ``` -Capabilities are closed: `text`, `workspace_edit`, `progress`, `cancel`, `commit_subject`. `initialized.capabilities` is a duplicate-free subset of the offer and must include the operation capability. Unknown or unoffered selections fail. +Capabilities are closed: `text`, `workspace_edit`, `progress`, `cancel`, `commit_subject`, `agent_report`. `initialized.capabilities` is a duplicate-free subset of the offer and must include the operation capability. Unknown or unoffered selections fail. ## Operations and terminals @@ -38,7 +38,9 @@ Workspace execution and success: {"version":"v1","id":"0123456789abcdef0123456789abcdef","type":"result","commit_subject":"feat: describe the edit"} ``` -The workspace result has exactly the common fields and optional `commit_subject`. If present, `commit_subject` requires negotiation, is 1..256 UTF-8 bytes, has no leading/trailing Unicode whitespace, Unicode control or format characters, U+2028/U+2029, or logical second line. When absent, Worker uses `chore: apply coding task`. Worker passes it as one argv element. +`agent_report` is an optional v1 capability. Workers offer it; plugins select it only when offered. Workspace results may contain `summary` and `changes` only when selected. Otherwise plugins omit both fields and receivers strictly reject either field, including null. Legacy `commit_subject` results remain valid. The v1 limits and version are unchanged. + +The workspace result has the common fields, optional `commit_subject`, and an optional report pair: `summary` and `changes`. Legacy results without the report remain valid. A report has a non-empty summary of at most 1024 UTF-8 bytes and 1–12 non-empty change strings of at most 256 UTF-8 bytes each. Report strings must be trimmed and contain no Unicode control/format characters or U+2028/U+2029. Unknown, duplicate, malformed Unicode, or partially supplied report fields fail closed. The report is advisory and self-reported, never check evidence. Worker encodes it canonically into the existing candidate result only after successful checks and candidate creation; Gate persists it with the producing attempt. Progress frames remain transient and are not stored. If present, `commit_subject` requires negotiation, is 1..256 UTF-8 bytes, has no leading/trailing Unicode whitespace, Unicode control or format characters, U+2028/U+2029, or logical second line. When absent, Worker uses `chore: apply coding task`. Worker passes it as one argv element. Progress requires negotiation, is limited to 128 frames, and has monotonically consecutive sequence numbers starting at 1. `stage` is one of `started`, `working`, `finalizing`; `text` is at most 1,024 UTF-8 bytes: diff --git a/internal/gate/app/app.css b/internal/gate/app/app.css new file mode 100644 index 0000000..4d3a165 --- /dev/null +++ b/internal/gate/app/app.css @@ -0,0 +1,559 @@ +/* Embedded, dependency-free operator workspace. */ +:root { + color-scheme:dark; + font:13px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; + color:#e3e4e9; + background:#101114; + --muted:#9296a3; + --border:#292b33; + --primary:#6665e8; +} +* { + box-sizing:border-box; +} +body { + margin:0; + display:flex; + min-height:100vh; +} +button,input,select,textarea { + font:inherit; + color:inherit; +} +button,select { + cursor:pointer; +} +button { + border:1px solid var(--border); + background:#1b1d23; + border-radius:6px; + padding:7px 12px; + font-weight:500; +} +button:hover { + background:#262832; +} +button:disabled { + opacity:.45; + cursor:not-allowed; +} +.primary { + background:var(--primary); + border-color:var(--primary); + color:white; +} +.primary:hover { + background:#7776ed; +} +input,select,textarea { + background:#14151a; + border:1px solid #373944; + border-radius:6px; + padding:9px 10px; + max-width:100%; + width:100%; +} +textarea { + resize:vertical; +} +label { + display:block; + margin:14px 0 5px; + font-weight:500; +} +p { + margin:6px 0 12px; +} +h1,h2,h3 { + margin:0; + font-weight:600; + letter-spacing:-.025em; +} +h1 { + font-size:23px; +} +h2 { + font-size:18px; +} +h3 { + font-size:14px; +} +small,.hint { + font-size:12px; + color:var(--muted); +} +small { + display:block; + margin-top:8px; +} +a { + color:#b6b5ff; + text-underline-offset:3px; + overflow-wrap:anywhere; +} +button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,a:focus-visible,summary:focus-visible { + outline:2px solid #aaa9ff; + outline-offset:3px; +} +[hidden] { + display:none!important; +} +.skip { + position:absolute; + left:12px; + top:-50px; + z-index:10; + background:#262832; + padding:10px; +} +.skip:focus { + top:10px; +} +.sidebar { + width:224px; + flex-shrink:0; + background:#141519; + border-right:1px solid var(--border); + padding:25px 16px; + display:flex; + flex-direction:column; + gap:7px; +} +.brand { + font-weight:650; + display:flex; + align-items:center; + gap:8px; + flex-wrap:wrap; + margin-bottom:30px; +} +.mark { + display:grid; + place-items:center; + width:25px; + height:25px; + border:1px solid #555966; + border-radius:7px; +} +.edition { + font-size:8px; + letter-spacing:.14em; + color:var(--muted); +} +.workspace-label,.eyebrow { + font-size:10px; + font-weight:600; + letter-spacing:.13em; + color:var(--muted); +} +.workspace-label { + margin:20px 6px 7px; +} +.nav { + width:100%; + text-align:left; + border-color:transparent; + background:transparent; + margin:2px 0; + color:#b2b5bf; +} +.nav.active { + background:#262638; + color:#cfceff; +} +.project-info { + color:var(--muted); + padding:10px 5px; + font-size:12px; + overflow-wrap:anywhere; +} +.project-info p { + margin-bottom:8px; +} +.sidebar-foot { + margin-top:auto; + padding-top:50px; + font-size:11px; +} +.sidebar-foot span { + color:var(--muted); +} +main { + min-width:0; + flex:1; + padding:28px 28px 40px; +} +header { + display:flex; + align-items:center; + justify-content:space-between; + gap:16px; + padding-bottom:24px; + border-bottom:1px solid var(--border); +} +.eyebrow { + margin-bottom:5px; +} +.actions { + display:flex; + gap:8px; + align-items:center; +} +.toolbar { + display:flex; + justify-content:space-between; + align-items:center; + margin:20px 0; + gap:16px; +} +#notice { + color:var(--muted); + margin:0; +} +.auth-panel { + margin-top:24px; + background:#191a20; + border:1px solid var(--border); + border-radius:9px; + padding:24px; + display:flex; + gap:32px; + align-items:center; + justify-content:space-between; +} +.auth-panel p { + color:var(--muted); +} +.auth-panel form { + width:360px; + max-width:100%; +} +.auth-panel label { + margin-top:0; +} +.board { + display:grid; + grid-template-columns:repeat(6,minmax(215px,1fr)); + gap:14px; + overflow-x:auto; + padding:0 0 20px; + min-height:420px; +} +.column { + min-width:0; +} +.column-heading { + display:flex; + align-items:center; + gap:8px; + margin:0 0 15px; + padding:7px 2px; + font-weight:600; +} +.dot { + width:8px; + height:8px; + border-radius:50%; + background:#757984; +} +.column:nth-child(2) .dot { + background:#a4a3fa; +} +.column:nth-child(3) .dot { + background:#b5a27d; +} +.column:nth-child(4) .dot { + background:#819c8d; +} +.column:nth-child(5) .dot { + background:#b1848e; +} +.count { + color:var(--muted); + font-size:11px; + background:#202127; + border-radius:4px; + padding:0 6px; + margin-left:auto; +} +.card { + border:1px solid var(--border); + border-radius:7px; + background:#1a1b21; + padding:14px; + margin-bottom:10px; +} +.card:hover { + border-color:#444753; +} +.card-title { + padding:0; + border:0; + background:none; + text-align:left; + font-size:13px; + line-height:1.5; + width:100%; + overflow-wrap:anywhere; +} +.card-title:hover { + background:none; + color:white; +} +.card-meta { + font-size:11px; + color:var(--muted); + margin-top:9px; + overflow-wrap:anywhere; +} +.card-meta p { + margin:5px 0; +} +.tag { + display:inline-block; + background:#24262e; + border:1px solid #32353f; + padding:1px 6px; + border-radius:4px; + color:#bfc3cf; +} +.empty { + border:1px dashed #2d3038; + border-radius:7px; + padding:24px 12px; + color:var(--muted); + text-align:center; + font-size:12px; +} +.summary { + margin-bottom:18px; + color:var(--muted); +} +.worker-grid { + display:grid; + grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); + gap:15px; +} +.worker-card { + margin:0; +} +.worker-heading { + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; +} +.worker-state { + font-size:11px; + color:#b1b5c1; +} +.worker-card dl { + margin-bottom:0; +} +dl { + display:grid; + grid-template-columns:minmax(95px,1fr) 2fr; + gap:8px 16px; + font-size:12px; +} +dt { + color:var(--muted); +} +dd { + margin:0; + overflow-wrap:anywhere; +} +dialog { + width:560px; + max-width:calc(100vw - 32px); + max-height:calc(100dvh - 32px); + background:#191a20; + border:1px solid #3a3d49; + border-radius:12px; + padding:24px; + color:inherit; + box-shadow:0 24px 90px #0009; +} +dialog::backdrop { + background:#0009; +} +.dialog-heading { + display:flex; + align-items:start; + justify-content:space-between; + gap:20px; + margin-bottom:18px; +} +.dialog-heading button { + font-size:20px; + line-height:1; + padding:6px 9px; +} +.dialog-actions { + display:flex; + align-items:center; + justify-content:space-between; + gap:20px; + margin-top:20px; +} +.drawer { + margin:0 0 0 auto; + height:100dvh; + max-height:100dvh; + max-width:100vw; + width:590px; + border-radius:0; + border-top:0; + border-bottom:0; + border-right:0; + padding:28px; +} +.detail-section { + border-top:1px solid var(--border); + margin-top:22px; + padding-top:18px; +} +.instruction { + white-space:pre-wrap; + overflow-wrap:anywhere; + font:inherit; + color:#c4c7d1; +} +.timeline { + padding-left:20px; + color:#b6bac5; +} +.timeline li { + padding:5px 0; +} +.attempt { + border:1px solid var(--border); + border-radius:6px; + padding:12px; + margin-top:12px; +} +.attempt p { + margin:6px 0; +} +.error,#task-error { + color:#eea7ad!important; +} +details { + margin-top:20px; +} +summary { + cursor:pointer; + color:var(--muted); + padding:8px 0; +} +.diagnostics { + white-space:pre-wrap; + overflow-wrap:anywhere; + font-size:11px; +} +@media(min-width:1600px) { + main { + padding:32px 40px; + } + .board { + gap:18px; + } +} +@media(max-width:1000px) { + .sidebar { + width:190px; + } + main { + padding:22px 20px; + } + .auth-panel { + display:block; + } + .auth-panel form { + width:100%; + } +} +@media(max-width:650px) { + body { + display:block; + } + .sidebar { + width:100%; + border-right:0; + border-bottom:1px solid var(--border); + padding:14px 18px; + display:block; + } + .brand { + margin:0 0 12px; + } + .sidebar nav { + display:flex; + } + .sidebar .workspace-label,.sidebar-foot,.project-info { + display:none; + } + .sidebar select { + margin-top:10px; + } + .nav { + width:auto; + flex:1; + } + main { + padding:20px 16px; + } + header { + align-items:flex-start; + } + header .actions { + flex-direction:column; + align-items:stretch; + } + h1 { + font-size:20px; + } + .board { + grid-template-columns:repeat(6,260px); + } + .auth-panel { + padding:18px; + } + .dialog-actions { + align-items:flex-start; + } + .drawer { + padding:20px; + } +} +@media(prefers-reduced-motion:no-preference) { + button { + transition:background .12s,border-color .12s; + } +} + +/* Task → run → attempt hierarchy, with long briefs disclosed on demand. */ +.drawer { width: 780px; } +.drawer > .dialog-heading { position: sticky; top: -28px; z-index: 2; background: #191a20; padding: 20px 0 16px; border-bottom: 1px solid var(--border); } +.drawer .instruction { max-width: 68ch; font-size: 16px; line-height: 1.5; white-space: pre-wrap; margin: 16px 0 24px; } +.task-summary { padding: 4px 0 14px; } +.task-summary dl { max-width: 620px; margin: 10px 0 16px; } +.task-summary .outcome { font-size: 16px; line-height: 1.5; } +.task-summary > a { display: inline-block; margin-right: 16px; } +.task-summary > button { display: block; margin-top: 16px; } +.run-group { margin: 24px 0 32px; padding: 20px 0; border-top: 1px solid var(--border); } +.run-group > h3 { font-size: 19px; } +.attempt { margin: 16px 0; padding: 16px; } +.attempt h4 { margin: 0 0 8px; font-size: 14px; } +.outcome { color: #e3e4e9; line-height: 1.5; } +.technical-field { display: grid; grid-template-columns: 140px minmax(0,1fr) auto; gap: 10px; align-items: center; margin: 12px 0; } +.technical-field code { overflow-wrap: anywhere; font-size: 12px; user-select: all; } +#run-status { width: 260px; margin-bottom: 16px; } +#backlog-notice { color: var(--muted); max-width: 75ch; } +@media (max-width: 650px) { .drawer > .dialog-heading { top: -20px; } .technical-field { grid-template-columns: 1fr; } } + +.project-switch { display:flex; align-items:center; gap:12px; margin-top:16px; } +.project-switch label { margin:0; } +.project-switch select { max-width:260px; } +#project-nav { display:block; overflow-y:auto; max-height:45vh; } +#project-nav .nav { display:block; width:100%; overflow-wrap:anywhere; } diff --git a/internal/gate/app/app.js b/internal/gate/app/app.js new file mode 100644 index 0000000..af710ad --- /dev/null +++ b/internal/gate/app/app.js @@ -0,0 +1,656 @@ +"use strict"; + +const statusColumns = Object.freeze({ + pending: 'Ready', retry_wait: 'Ready', leased: 'Working', + delivering: 'Review & CI', succeeded: 'Done', failed: 'Blocked' +}); +const columns = ['Backlog', 'Ready', 'Working', 'Review & CI', 'Done', 'Blocked']; +const backlogs = new Map(); +let backlogRequest = 0, backlogLoad = Promise.resolve(); +const byId = id => document.getElementById(id); +let token = '', overview = null, selectedJob = '', submitPending = false, refreshing = false; +let detailEpoch = 0, detailRequest = 0, startRequest = 0; +let session = 0, selectedTask = null, selectedActivity = null; + +function add(parent, tag, text, className = '') { + const node = document.createElement(tag); + node.textContent = text; + node.className = className; + parent.appendChild(node); + return node; +} +function button(parent, text, action, className = '') { + const node = add(parent, 'button', text, className); + node.type = 'button'; + node.addEventListener('click', action); + return node; +} +function safeLink(parent, value, label) { + if (!value) return; + try { + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) return; + const link = add(parent, 'a', label); + link.href = url.href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + } catch { /* Absent or unsafe source: no link. */ } +} +function stamp(value) { + if (!value || value.startsWith('0001-')) return 'Not reported'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? 'Not reported' : date.toLocaleString(); +} +function duration(job) { + const end = ['succeeded', 'failed'].includes(job.status) ? new Date(job.updated_at).getTime() : Date.now(); + const seconds = Math.max(0, Math.floor((end - new Date(job.created_at).getTime()) / 1000)); + if (!Number.isFinite(seconds)) return 'Not reported'; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`; +} +function fields(parent, pairs) { + const list = add(parent, 'dl', ''); + for (const [label, value] of pairs) { + add(list, 'dt', label); + add(list, 'dd', value || 'Not reported'); + } +} +function notice(message, error = false) { + byId('notice').textContent = message; + byId('notice').className = error ? 'error' : ''; +} +function lockWorkspace(message = 'Workspace locked.') { + session++; + token = ''; overview = null; selectedJob = ''; selectedTask = null; selectedActivity = null; backlogs.clear(); backlogRequest++; + byId('backlog-notice').textContent = 'Select a project to load its public issue backlog.'; + byId('backlog-refresh').disabled = true; + byId('token').value = ''; + byId('auth-panel').hidden = false; + byId('lock').hidden = true; + for (const id of ['new-task', 'refresh', 'project-filter']) byId(id).disabled = true; + for (const id of ['board', 'workers', 'worker-summary', 'detail-content', 'task-project', 'runs', 'runs-summary', 'project-nav']) byId(id).replaceChildren(); + for (const id of ['task-modal', 'detail-modal']) if (byId(id).open) byId(id).close(); + byId('task-form').reset(); + byId('project-filter').replaceChildren(); + add(byId('project-filter'), 'option', 'All projects').value = ''; + byId('project-info').textContent = 'Connect to see configured projects.'; + notice(message, message.startsWith('Unauthorized')); +} +async function api(path, body) { + const currentSession = session; + let response; + try { + response = await fetch(path, { + method: body === undefined ? 'GET' : 'POST', + headers: {Authorization: `Bearer ${token}`, ...(body === undefined ? {} : {'Content-Type': 'application/json'})}, + ...(body === undefined ? {} : {body: JSON.stringify(body)}), + cache: 'no-store', credentials: 'omit' + }); + } catch { + throw new Error(body === undefined ? 'Offline — unable to reach the gate. Reconnecting every 5 seconds.' : 'Connection lost. Check the board before submitting again; the task may have been created.'); + } + if (currentSession !== session) throw new Error('Workspace session changed.'); + if (response.status === 401) { + lockWorkspace('Unauthorized — enter a valid owner token.'); + byId('token').focus(); + throw new Error('Unauthorized — enter a valid owner token.'); + } + if (!response.ok) { + const messages = {400: 'Invalid task. Check the project, title, instructions, source URL and checks.', 404: 'Task not found.', 413: 'Task detail or request exceeds the supported limit.', 422: 'Public source preparation is unavailable. Check the project configuration and default branch.', 502: 'Repository preparation failed. The public source could not be fetched.'}; + throw new Error(messages[response.status] || `Request failed (${response.status}). Try refreshing.`); + } + const data = await response.json(); + if (currentSession !== session) throw new Error('Workspace session changed.'); + return data; +} +function projectInfo() { + const selected = overview?.projects.find(p => p.id === byId('project-filter').value); + const target = byId('project-info'); target.replaceChildren(); + if (!selected) { + add(target, 'p', overview ? `${overview.projects.length} configured projects` : 'Connect to see configured projects.'); + return; + } + for (const text of [`Branch · ${selected.default_branch}`, `Pool · ${selected.worker_pool}`, `Plugin agent · ${selected.agent}`, `Public source · ${selected.public_source ? 'Available' : 'Unavailable'}`, `Delivery · ${selected.delivery ? 'Available' : 'Unavailable'}`]) add(target, 'p', text); +} +function taskLane(task) { + if (task.issue?.state === 'closed') return 'Done'; + if (task.issue?.labels?.includes('blocked')) return 'Blocked'; + if (task.latest_run && statusColumns[task.latest_run.status]) return statusColumns[task.latest_run.status]; + return task.issue?.labels?.includes('ready-for-agent') ? 'Ready' : 'Backlog'; +} +function taskKey(task) { return `${task.project} ${task.source_ref}`; } +function boardTasks() { + const tasks = new Map((overview?.tasks || []).map(task => [taskKey(task), {...task}])); + const filter = byId('project-filter').value; + for (const [project, backlog] of backlogs) { + if (!backlog.available || (filter && project !== filter)) continue; + for (const issueTask of backlog.tasks) { + const local = tasks.get(taskKey(issueTask)); + const task = {...issueTask}; + if (local?.latest_run && (!task.latest_run || local.latest_run.created_at >= task.latest_run.created_at)) { + task.latest_run = local.latest_run; task.run_count = local.run_count; + } + tasks.set(taskKey(task), task); + } + } + for (const task of tasks.values()) { + // Recent run updates also refresh issues outside the bounded local-task list. + for (const run of overview?.jobs || []) { + if (run.project === task.project && run.source_ref === task.source_ref && (!task.latest_run || run.id === task.latest_run.id || run.created_at > task.latest_run.created_at)) task.latest_run = run; + } + } + return [...tasks.values()]; +} +function renderBoard() { + const focusedTask = document.activeElement?.dataset?.jobId; + const board = byId('board'); board.replaceChildren(); + const filter = byId('project-filter').value; + const tasks = boardTasks().filter(task => !filter || task.project === filter); + const empty = {'Backlog':'No unscheduled open issues in the loaded project feeds.', 'Ready':'No issues labeled ready-for-agent or queued linked runs.', 'Working':'No linked coding run is working.', 'Review & CI':'No linked run is awaiting delivery or CI.', 'Done':'No closed issues or successful linked runs loaded.', 'Blocked':'No blocked issues or failed linked runs loaded.'}; + for (const name of columns) { + const column = add(board, 'section', '', 'column'); column.setAttribute('aria-label', name); + const heading = add(column, 'h2', '', 'column-heading'); + add(heading, 'span', '', 'dot').setAttribute('aria-hidden', 'true'); add(heading, 'span', name); + const cards = tasks.filter(task => taskLane(task) === name); + add(heading, 'span', String(cards.length), 'count'); + if (!cards.length) add(column, 'p', overview ? empty[name] : 'Connect to load tasks.', 'empty'); + for (const task of cards) { + const card = add(column, 'article', '', 'card'); + const taskButton = button(card, task.title, () => openTaskDetail(task), 'card-title'); + taskButton.dataset.jobId = taskKey(task); + if (focusedTask === taskKey(task)) taskButton.focus({preventScroll:true}); + const meta = add(card, 'div', '', 'card-meta'); + add(meta, 'span', task.project || 'Unassigned project', 'tag'); + add(meta, 'p', task.issue ? `Issue #${task.issue.number}` : 'Linked local task · issue state not loaded'); + const run = task.latest_run; + if (run) { + add(meta, 'p', `Latest run · ${run.worker_id || 'Awaiting worker'} · ${run.agent || 'Agent not reported'}`); + if (run.delivery) add(meta, 'p', `Delivery · ${deliveryPhase(run.delivery.phase)}`); + add(meta, 'p', `Updated ${stamp(run.updated_at)} · ${duration(run)} elapsed`); + } else add(meta, 'p', 'No Forge runs yet.'); + safeLink(meta, task.source_ref, 'Open source ↗'); + } + } +} +function runState(status) { + return {pending:'Queued',retry_wait:'Waiting for another attempt',leased:'Working',delivering:'Review & CI',succeeded:'Succeeded',failed:'Failed'}[status] || 'Not reported'; +} +function deliveryPhase(phase) { + return {pending:'Waiting to publish',publishing:'Publishing candidate',ci:'Waiting for CI',merging:'Merging pull request',retry_wait:'Waiting for another delivery attempt',merged:'Merged',failed:'Delivery stopped'}[phase] || 'Not reported'; +} +function ciState(state) { return {pending:'Pending',success:'Passed',failure:'Failed',failed:'Failed'}[state] || 'Not reported'; } +function renderRuns() { + const project = byId('project-filter').value, status = byId('run-status').value; + const all = overview?.jobs || []; + const runs = all.filter(run => (!project || run.project === project) && (!status || run.status === status)); + byId('runs-summary').textContent = `${runs.length} recent runs · ${runs.filter(run => !run.source_ref).length} unlinked${overview?.jobs_truncated ? ' · Latest 100 runs only' : ''}. Runs are execution history, not product tasks.`; + const root = byId('runs'); root.replaceChildren(); + if (!runs.length) add(root, 'p', 'No runs match this project and status.', 'empty'); + for (const run of runs) { + const card = add(root, 'article', '', 'card'); + button(card, `Run/Job · ${run.title}`, () => openDetail(run.id), 'card-title'); + add(card, 'p', `${run.project || 'Unassigned project'} · ${runState(run.status)} · ${stamp(run.created_at)}`, 'hint'); + if (run.source_ref) safeLink(card, run.source_ref, 'Linked source ↗'); + else add(card, 'p', 'Unlinked run — no product task source.', 'hint'); + } +} +function renderProjectNav() { + const target = byId('project-nav'), selected = byId('project-filter').value; + const focused = document.activeElement?.dataset?.project; + target.replaceChildren(); + for (const [id, label] of [['', 'All projects'], ...(overview?.projects || []).map(p => [p.id, p.id])]) { + const node = button(target, label, () => selectProject(id), selected === id ? 'nav active' : 'nav'); + node.dataset.project = id; node.setAttribute('aria-current', selected === id ? 'true' : 'false'); + node.disabled = !overview; + if (focused === id) node.focus({preventScroll:true}); + } +} +function selectProject(id) { + byId('project-filter').value = id; + renderProjectNav(); projectInfo(); renderBoard(); renderRuns(); renderWorkers(); + return loadBacklog(); +} +function backlogProjects() { + const selected = byId('project-filter').value; + return (overview?.projects || []).filter(p => p.public_source && (!selected || p.id === selected)); +} +function backlogNotice(loading = false) { + const projects = backlogProjects(), missing = projects.filter(p => backlogs.get(p.id)?.available === false); + const count = projects.reduce((n,p) => n + (backlogs.get(p.id)?.available ? backlogs.get(p.id).tasks.length : 0), 0); + const scope = byId('project-filter').value || 'All projects'; + const unavailable = missing.length ? ` Backlog unavailable: ${missing.slice(0,5).map(p => p.id).join(', ')}${missing.length > 5 ? ` (+${missing.length - 5} more)` : ''}. Linked local tasks remain visible.` : ''; + const limited = projects.some(p => backlogs.get(p.id)?.truncated) ? ' Feeds limited to 50 upstream records each.' : ''; + byId('backlog-notice').textContent = projects.length ? `${scope} · ${count} public issue records${loading ? ' · Loading…' : ' · Backlogs refresh every 5 min.'}${limited}${unavailable}` : `${scope} · Public issue backlog unavailable: no public-source projects configured.`; +} +function loadBacklog(force = false) { + const request = ++backlogRequest, currentSession = session; + const projects = backlogProjects(); + byId('backlog-refresh').disabled = !token || !projects.length; + const current = () => request === backlogRequest && currentSession === session && !!token; + backlogNotice(true); + // Serialize batches so navigation cannot multiply the three-request ceiling. + const batch = async () => { + let next = 0; + const worker = async () => { + while (current() && next < projects.length) { + const project = projects[next++]; + if (!force && Date.now() - (backlogs.get(project.id)?.checkedAt || 0) < 300000) continue; + let entry; + try { + const data = await api(`/v1/control/projects/${encodeURIComponent(project.id)}/issues`); + entry = {available:true, tasks:data.tasks, truncated:data.truncated, checkedAt:Date.now()}; + } catch { entry = {available:false, tasks:[], checkedAt:Date.now()}; } + if (!current()) return; + backlogs.set(project.id,entry); renderBoard(); backlogNotice(true); + } + }; + await Promise.all(Array.from({length:Math.min(3,projects.length)}, worker)); + if (current()) { renderBoard(); backlogNotice(); } + }; + backlogLoad = backlogLoad.then(batch, batch); + return backlogLoad; +} +function renderWorkers() { + const filter = byId('project-filter').value; + const project = overview.projects.find(p => p.id === filter); + const workers = overview.workers.filter(w => !project || w.pool === project.worker_pool); + const connected = workers.filter(w => w.connected).length; + const occupied = workers.filter(w => w.occupied).length; + const free = workers.filter(w => w.connected && !w.occupied).length; + byId('worker-summary').textContent = `${workers.length} slots · ${connected} connected · ${occupied} occupied · ${free} connected and free${overview.workers_truncated ? ' · Runtime history limited' : ''}`; + const target = byId('workers'); target.replaceChildren(); + if (!workers.length) add(target, 'p', 'No worker slots available for this project.', 'empty'); + for (const worker of workers) { + const card = add(target, 'article', '', 'card worker-card'); + const heading = add(card, 'div', '', 'worker-heading'); + add(heading, 'h2', worker.base_id || worker.id); + add(heading, 'span', worker.connected ? '● Connected' : '○ Offline', 'worker-state'); + const agents = [...new Set(overview.projects.filter(p => p.worker_pool === worker.pool).map(p => p.agent))]; + fields(card, [['Slot', String(worker.slot)], ['Pool', worker.pool], ['Capacity', worker.occupied ? 'Occupied · 1 of 1' : worker.connected ? 'Free · 1 of 1' : 'Offline · unavailable'], ['Plugin agent', worker.agent || (agents.length ? `Configured for pool: ${agents.join(', ')}` : 'Not reported')], ['Last seen / heartbeat', stamp(worker.last_seen)]]); + if (worker.active_job_id) { + const job = overview.jobs.find(j => j.id === worker.active_job_id); + button(card, job ? job.title : 'Inspect assigned run', () => openDetail(worker.active_job_id)); + } else add(card, 'p', 'No assigned active job', 'hint'); + } +} +function renderOverview(data) { + overview = data; + const filter = byId('project-filter'), previous = filter.value; + filter.replaceChildren(); add(filter, 'option', 'All projects').value = ''; + for (const project of data.projects) add(filter, 'option', project.id).value = project.id; + filter.value = data.projects.some(p => p.id === previous) ? previous : ''; + filter.disabled = false; + byId('new-task').disabled = !data.projects.some(p => p.public_source); + byId('refresh').disabled = false; + byId('auth-panel').hidden = true; byId('lock').hidden = false; + for (const id of backlogs.keys()) if (!data.projects.some(p => p.id === id && p.public_source)) backlogs.delete(id); + renderProjectNav(); projectInfo(); renderBoard(); renderWorkers(); renderRuns(); +} +async function refresh() { + if (!token || refreshing) return; + refreshing = true; + const currentSession = session; + notice(overview ? 'Refreshing…' : 'Loading projects, worker slots and work…'); + try { + const data = await api('/v1/control/overview'); + if (currentSession !== session) return; + renderOverview(data); + notice(`${(data.tasks || []).length} linked local tasks (up to 100 sources) · ${data.jobs.length} recent runs${data.jobs_truncated ? ' · Showing the latest 100; older runs are outside this view' : ''} · Updated ${new Date().toLocaleTimeString()} · Refreshes every 5s`); + await loadBacklog(); + if (byId('detail-modal').open) { + if (selectedTask) await loadTaskDetail(selectedTask); + else if (selectedJob) await loadDetail(selectedJob); + } + } catch (error) { if (currentSession === session) notice(error.message, true); } + finally { refreshing = false; } +} +function section(parent, title) { + const node = add(parent, 'section', '', 'detail-section'); add(node, 'h3', title); return node; +} +function elapsedMS(ms) { + if (!Number.isFinite(ms) || ms < 0) return 'an unreported duration'; + if (ms < 1000) return `${Math.round(ms)}ms`; + if (ms < 60000) return `${Math.round(ms / 1000)}s`; + return `${Math.round(ms / 60000)} min`; +} +function evidenceSummary(evidence, job) { + const check = `Check ${(evidence.check_index ?? 0) + 1}`, elapsed = elapsedMS(evidence.duration_ms); + if (evidence.reason === 'scoped_check_passed') return `${check} passed in ${elapsed}.`; + if (evidence.reason === 'scoped_check_failed') return `${check} failed after ${elapsed}.`; + if (evidence.reason === 'scoped_check_timeout') return `${check} timed out after ${elapsed}.`; + if (evidence.reason === 'plugin_protocol_failed') { + return job.plugin_timeout_ms > 0 && evidence.duration_ms >= job.plugin_timeout_ms + ? `Coding agent timed out after ${elapsed}.` + : `Coding agent did not return a valid result after ${elapsed}.`; + } + const phrases = { + cleanup_failed:'Temporary workspace cleanup needs attention.', + plugin_start_failed:'The coding agent could not start.', plugin_reported_failure:'The coding agent reported a failure.', + plugin_failed:'The coding agent did not finish.', no_changes:'The coding agent produced no changes.', + invalid_workspace_change:'Workspace changes did not pass validation.', candidate_commit_failed:'Forge could not save a candidate commit.', + clone_failed:'Forge could not clone the public source.', fetch_failed:'Forge could not refresh the public source.', + base_unavailable:'The pinned starting commit was unavailable.', invalid_task:'The run instructions failed validation.', + invalid_repository:'The repository failed validation.', source_policy_invalid:'The public source policy failed validation.', + repository_state_unsafe:'Repository safety checks did not pass.', runtime_setup_failed:'The execution environment could not be prepared.', + worktree_setup_failed:'The working copy could not be prepared.' + }; + return phrases[evidence.reason] || 'Forge reported execution evidence; see the technical reason below.'; +} +function attemptOutcome(attempt, job, hasNext = false) { + const evidence = attempt.evidence || []; + if (attempt.status === 'succeeded' && evidence.some(e => e.reason === 'cleanup_failed')) return 'Work completed, but temporary workspace cleanup needs attention.'; + const problem = evidence.find(e => e.reason !== 'scoped_check_passed' && e.reason !== 'cleanup_failed'); + if (problem) return evidenceSummary(problem, job); + if (attempt.failure_code === 'execution_failed') return attempt.failure_disposition === 'retryable' && (hasNext || job.status === 'retry_wait') + ? 'Coding agent did not finish; Forge scheduled another attempt.' : 'Coding agent did not finish; no further attempt is scheduled.'; + if (attempt.status === 'leased') return 'Coding agent is working; a result has not been reported.'; + if (attempt.candidate_sha) return 'Produced a candidate commit.'; + if (attempt.status === 'succeeded') return 'Run completed successfully.'; + if (attempt.status === 'failed' || attempt.status === 'expired') return 'This attempt did not complete successfully.'; + return 'No outcome reported yet.'; +} +function runAction(job) { + const states = {pending:'Waiting for an available worker.', retry_wait:'Forge scheduled another attempt.', leased:'Coding agent is working.', delivering:'Forge is delivering the candidate for review and CI.', succeeded:'Run completed successfully.', failed:'Run stopped after failure.'}; + return states[job.status] || 'Run state not reported.'; +} +function nextAction(attempt, job, hasNext) { + if (hasNext) return 'Forge started the next attempt shown below.'; + if (job.status === 'retry_wait') return 'Forge scheduled another attempt.'; + if (attempt.status === 'leased') return 'Forge is waiting for the coding agent result.'; + if (job.status === 'delivering') return 'Forge moved the candidate to review and CI.'; + if (job.status === 'succeeded') return job.delivery?.phase === 'merged' ? 'Forge merged the delivery.' : 'Forge marked this run successful.'; + return 'Forge stopped this run; no further attempt is scheduled.'; +} +function copyField(parent, label, value) { + if (!value) return; + const row = add(parent, 'div', '', 'technical-field'); + add(row, 'span', label, 'hint'); add(row, 'code', String(value)); + const copy = button(row, 'Copy', async () => { + try { await navigator.clipboard.writeText(String(value)); copy.textContent = 'Copied'; } + catch { copy.textContent = 'Select value to copy'; } + }); + copy.setAttribute('aria-label', `Copy ${label}`); +} +function renderIssueCompletion(node, issue, activity) { + node.replaceChildren(); + if (issue.state === 'closed') { + const closed = activity?.data?.issue || issue; + fields(node, [['Closed by', closed.closed_by || issue.closed_by || 'Not reported'], ['Closed', stamp(closed.closed_at || issue.closed_at)]]); + } + const prs = activity?.data?.merged_prs || []; + for (const pr of prs) { + const proof = pr.details?.delivery_evidence === true; + const delivered = section(node, proof ? 'Delivered changes' : 'Related merged PR'); + add(delivered, 'p', proof ? 'GitHub delivery evidence' : 'Related GitHub evidence', 'eyebrow'); + if (pr.details) { + add(delivered, 'h3', pr.details.title); + add(delivered, 'p', pr.details.summary, 'instruction'); + add(delivered, 'p', `${pr.details.changed_files} files · +${pr.details.additions} / −${pr.details.deletions}`, 'hint'); + const files = add(delivered, 'ul', ''); + for (const file of pr.details.files) add(files, 'li', `${file.filename} · ${file.status} · +${file.additions} / −${file.deletions}`); + if (pr.details.summary_truncated) add(delivered, 'p', 'Description shortened; read the full PR.', 'hint'); + if (pr.details.files_truncated) add(delivered, 'p', 'File list is partial (up to 20 safe entries).', 'hint'); + } else add(delivered, 'p', 'Related PR details unavailable.', 'hint'); + safeLink(delivered, pr.url, `${proof ? 'Closing PR' : 'Related merged PR'} #${pr.number} ↗`); + add(delivered, 'p', `Merged ${stamp(pr.merged_at)}`, 'hint'); + } + if (activity?.status === 'loading') add(node, 'p', 'Loading additional GitHub evidence…', 'hint'); + if (activity?.status === 'unavailable') add(node, 'p', 'Additional GitHub activity unavailable', 'hint'); + if (activity?.data?.truncated) add(node, 'p', 'GitHub activity is limited to 50 records.', 'hint'); +} +async function loadIssueActivity(task, activity) { + const currentSession = session; + try { + activity.data = await api(`/v1/control/projects/${encodeURIComponent(task.project)}/issues/${task.issue.number}/activity`); + activity.status = activity.data.available ? 'available' : 'unavailable'; + } catch { activity.status = 'unavailable'; } + if (token && session === currentSession && selectedActivity === activity && activity.node) renderIssueCompletion(activity.node, task.issue, activity); +} +function renderTaskDetail(task, runs, total = runs.length) { + const root = byId('detail-content'); + const expanded = new Set([...root.querySelectorAll('details')].filter(node => node.open).map(node => node.dataset.disclosure)); + const focused = document.activeElement?.dataset?.disclosure; + root.replaceChildren(); + const disclosure = (parent, label, key) => { + const node = add(parent, 'details', ''); node.dataset.disclosure = key; node.open = expanded.has(key); + const summary = add(node, 'summary', label); summary.dataset.disclosure = key; + if (focused === key) summary.focus({preventScroll:true}); + return node; + }; + const latest = runs[runs.length - 1], job = latest?.job || task?.latest_run; + const title = task?.title || job?.title || 'Run details'; + byId('detail-title').textContent = task ? title : `Run/Job · ${title}`; + const summary = add(root, 'section', '', 'task-summary'); + const identity = task?.issue ? `Issue #${task.issue.number}` : task ? 'Linked local task · issue state not loaded' : 'Run/Job · execution history'; + add(summary, 'p', identity, 'eyebrow'); + fields(summary, [['Lane / state', task ? taskLane({...task, latest_run:job}) : runState(job?.status)], ['Project', task?.project || job?.project], ['Worker / agent', job ? `${job.worker_id || 'Not assigned'} → ${job.agent || 'Not reported'}` : task?.issue?.state === 'closed' ? 'No linked Forge execution' : 'No run started']]); + const lastAttempt = latest?.attempts?.at(-1); + add(summary, 'p', lastAttempt ? attemptOutcome(lastAttempt, job) : job ? runAction(job) : task?.issue?.state === 'closed' ? 'Completed on GitHub. No Forge execution record is linked; this task predates tracking or was completed outside Forge.' : 'No Forge runs yet. Review the brief and start a run.', 'outcome'); + if (job?.delivery) { + add(summary, 'p', `Delivery: ${deliveryPhase(job.delivery.phase)} · CI: ${ciState(job.delivery.ci_state)}`, 'hint'); + safeLink(summary, job.delivery.pr_url, 'Open pull request ↗'); + } + safeLink(summary, task?.source_ref || job?.source_ref, 'Open task source ↗'); + if (task?.issue) { + const node = add(summary, 'section', '', 'completion-evidence'); + const activity = selectedActivity?.key === taskKey(task) ? selectedActivity : null; + if (activity) activity.node = node; + renderIssueCompletion(node, task.issue, activity); + } + const brief = disclosure(root, 'Full brief', 'brief'); + const briefText = task?.issue?.body || latest?.instruction || 'No brief reported.'; + add(brief, 'p', briefText, 'instruction'); + if (task?.issue?.body_truncated) add(brief, 'p', 'This issue brief is truncated at 8,000 characters. Read the full issue before starting a run.', 'hint'); + if (task && !['pending','retry_wait','leased','delivering'].includes(job?.status) && task.issue?.state !== 'closed') button(summary, 'Start run', () => startIssueTask(task), 'primary'); + const group = section(root, task ? `Runs · ${total}` : 'Run history'); + if (!runs.length) add(group, 'p', 'No runs linked to this exact source URL.', 'hint'); + if (total > runs.length) add(group, 'p', `Showing the latest ${runs.length} of ${total} runs.`, 'hint'); + for (const data of runs) { + const run = data.job, attempts = data.attempts || []; + const runNode = add(group, 'section', '', 'run-group'); + add(runNode, 'h3', `Run ${run.ordinal || 1}`); + add(runNode, 'p', runAction(run), 'outcome'); + add(runNode, 'p', `Started ${stamp(run.created_at)} · Updated ${stamp(run.updated_at)} · ${duration(run)} elapsed`, 'hint'); + if (data.error) { add(runNode, 'p', 'Run details unavailable. Refresh to try again.', 'error'); continue; } + const reportSection = section(runNode, 'Agent report'); + const reportedAttempts = attempts.filter(attempt => attempt.status === 'succeeded' && attempt.candidate_sha && attempt.agent_report); + if (!reportedAttempts.length) add(reportSection, 'p', 'Detailed agent report was not captured for this run', 'hint'); + for (const attempt of reportedAttempts) { + add(reportSection, 'p', `Self-reported · Attempt ${attempt.ordinal}`, 'eyebrow'); + add(reportSection, 'p', attempt.agent_report.summary, 'instruction'); + const changes = add(reportSection, 'ul', ''); + for (const change of attempt.agent_report.changes) add(changes, 'li', change); + } + const verification = section(runNode, 'Forge verification'); + if (data.delivery) { + fields(verification, [['Delivery', deliveryPhase(data.delivery.phase)], ['CI', ciState(data.delivery.ci_state)]]); + safeLink(verification, data.delivery.pr_url, 'Open run pull request ↗'); + } + if (data.instruction && data.instruction !== briefText) add(disclosure(runNode, 'Run brief', `run-brief-${run.id}`), 'p', data.instruction, 'instruction'); + add(runNode, 'p', `Worker slot → configured plugin agent · ${run.agent || 'Not reported'}`, 'hint'); + add(runNode, 'p', 'No subagent telemetry reported for this run', 'hint'); + if (!attempts.length) add(verification, 'p', 'No attempts yet; waiting for a worker.', 'hint'); + attempts.forEach((attempt, index) => { + const item = add(verification, 'section', '', 'attempt'); + add(item, 'h4', `Attempt ${attempt.ordinal}`); + const outcome = attemptOutcome(attempt, run, index < attempts.length - 1); + add(item, 'p', outcome, 'outcome'); + const completed = attempt.completed_at && !attempt.completed_at.startsWith('0001-') ? attempt.completed_at : null; + const elapsed = completed ? new Date(completed) - new Date(attempt.leased_at) : attempt.status === 'leased' ? Date.now() - new Date(attempt.leased_at) : NaN; + fields(item, [['Worker / agent', `${attempt.worker_id || 'Not reported'} → ${run.agent || 'Not reported'}`], ['Started', stamp(attempt.leased_at)], ['Completed', stamp(completed)], ['Duration', elapsedMS(elapsed)]]); + if (attempt.candidate_sha && !outcome.includes('candidate')) add(item, 'p', 'Produced a candidate commit.'); + const next = nextAction(attempt, run, index < attempts.length - 1); + if (!outcome.includes(next)) add(item, 'p', next); + if (attempt.failure_code) add(item, 'p', attempt.failure_code, 'hint'); + const reported = new Set([outcome]); + for (const evidence of attempt.evidence || []) { + const text = evidenceSummary(evidence, run); + if (!reported.has(text)) add(item, 'p', text); + reported.add(text); + add(item, 'p', evidence.reason, 'hint'); + } + }); + if (data.attempts_truncated) add(runNode, 'p', 'Only the first 100 attempts are available here.', 'hint'); + const messages = {submitted:'Run queued in Forge.', delivery_pending:'Candidate handed to delivery.', delivery_phase:'Delivery advanced', delivery_retry:'Forge scheduled another delivery attempt.', delivery_merged:'Pull request merged.', delivery_failed:'Delivery stopped after failure.'}; + const events = (data.timeline || []).filter(event => messages[event.type]); + if (events.length) { + const timeline = section(runNode, 'Run timeline'), list = add(timeline, 'ol', '', 'timeline'); + const seen = new Set(); + for (const event of events) { + const text = `${messages[event.type]}${event.type === 'delivery_phase' ? `: ${{publishing:'publishing the candidate',ci:'waiting for CI',merging:'merging the pull request'}[event.phase] || 'delivery in progress'}.` : ''}`; + if (!seen.has(text)) add(list, 'li', `${stamp(event.at)} · ${text}`); + seen.add(text); + } + if (data.timeline_truncated) add(timeline, 'p', 'Timeline is limited to the first 100 stored events.', 'hint'); + } + const diagnostics = disclosure(runNode, 'Diagnostics · technical fields', `diagnostics-${run.id}`); + for (const [key, label] of [['id','Run/Job ID'],['attempt_id','Current attempt ID'],['base_sha','Pinned base commit'],['candidate_sha','Candidate commit']]) copyField(diagnostics,label,data.diagnostics?.[key]); + copyField(diagnostics, 'Delivery branch', data.delivery?.branch); copyField(diagnostics, 'Merge commit', data.delivery?.merge_sha); + for (const attempt of attempts) { + copyField(diagnostics, `Attempt ${attempt.ordinal} ID`, attempt.id); + copyField(diagnostics, `Attempt ${attempt.ordinal} candidate`, attempt.candidate_sha); + for (const evidence of attempt.evidence || []) copyField(diagnostics, 'Evidence ID', evidence.evidence_id); + } + } +} +function renderDetail(data) { renderTaskDetail(null, [data]); } +async function loadTaskDetail(task) { + const key = taskKey(task), activity = selectedActivity, currentSession = session; + try { + const group = await api(`/v1/control/projects/${encodeURIComponent(task.project || '_unassigned')}/runs?source_ref=${encodeURIComponent(task.source_ref)}`); + const runs = await Promise.all(group.runs.map(async run => { + try { const data = await api(`/v1/control/jobs/${encodeURIComponent(run.id)}`); data.job.ordinal = run.ordinal; return data; } + catch { return {job:run,error:true}; } + })); + if (!token || session !== currentSession || selectedActivity !== activity || !selectedTask || taskKey(selectedTask) !== key) return; + const current = boardTasks().find(item => taskKey(item) === key) || task; + selectedTask = current; + renderTaskDetail(current, runs, group.total); byId('detail-notice').textContent = ''; + } catch (error) { if (session === currentSession && selectedActivity === activity && selectedTask && taskKey(selectedTask) === key) byId('detail-notice').textContent = error.message; } +} +function openTaskDetail(task) { + detailEpoch++; + selectedJob = ''; selectedTask = task; + selectedActivity = task.issue ? {key:taskKey(task), status:'loading'} : null; + byId('detail-content').replaceChildren(); renderTaskDetail(task, []); + byId('detail-notice').textContent = 'Loading linked runs…'; + if (!byId('detail-modal').open) byId('detail-modal').showModal(); + if (selectedActivity) loadIssueActivity(task, selectedActivity); + loadTaskDetail(task); +} +async function loadDetail(id) { + const request = ++detailRequest, epoch = detailEpoch, currentSession = session, currentToken = token, job = selectedJob; + const current = () => request === detailRequest && epoch === detailEpoch && currentSession === session && + currentToken === token && token && id === job && job === selectedJob && !selectedTask && byId('detail-modal').open; + if (!current()) return; + try { + const data = await api(`/v1/control/jobs/${encodeURIComponent(id)}`); + if (!current()) return; + renderDetail(data); byId('detail-notice').textContent = ''; + } catch (error) { if (current()) byId('detail-notice').textContent = error.message; } +} +function openDetail(id) { + detailEpoch++; + selectedJob = id; selectedTask = null; selectedActivity = null; + byId('detail-title').textContent = 'Run/Job details'; byId('detail-content').replaceChildren(); + byId('detail-notice').textContent = 'Loading run…'; + if (!byId('detail-modal').open) byId('detail-modal').showModal(); + loadDetail(id); +} +function taskProjectInfo() { + const project = overview.projects.find(p => p.id === byId('task-project').value); + byId('task-project-info').textContent = project ? `${project.default_branch} · ${project.agent} · Pool ${project.worker_pool} · ${project.delivery ? 'PR delivery available' : 'Candidate only; delivery unavailable'}` : 'No public-source project available.'; +} +function openTask() { + if (!overview || submitPending) return; + byId('task-form').reset(); byId('task-error').textContent = ''; byId('scoped-checks').hidden = true; byId('task-checks').required = false; + const select = byId('task-project'); select.replaceChildren(); + for (const project of overview.projects.filter(p => p.public_source)) add(select, 'option', project.id).value = project.id; + if (overview.projects.some(p => p.id === byId('project-filter').value && p.public_source)) select.value = byId('project-filter').value; + taskProjectInfo(); byId('task-modal').showModal(); byId('task-title').focus(); +} +async function startIssueTask(task) { + const request = ++startRequest; + if (submitPending) return; + if (!overview?.projects.some(p => p.id === task.project && p.public_source)) { + byId('detail-notice').textContent = 'Public source preparation is unavailable for this project.'; return; + } + let brief = task.issue?.body || ''; + if (!brief && task.latest_run?.id) { + const epoch = detailEpoch, activity = selectedActivity, currentSession = session, currentToken = token, key = taskKey(task); + const current = () => request === startRequest && epoch === detailEpoch && activity === selectedActivity && + currentSession === session && currentToken === token && token && byId('detail-modal').open && + selectedTask && taskKey(selectedTask) === key; + if (!current()) return; + try { brief = (await api(`/v1/control/jobs/${encodeURIComponent(task.latest_run.id)}`)).instruction; } + catch (error) { if (current()) byId('detail-notice').textContent = error.message; return; } + if (!current() || submitPending) return; + } + if (byId('detail-modal').open) byId('detail-modal').close(); + openTask(); + byId('task-project').value = task.project; byId('task-title').value = task.title; + byId('task-instruction').value = brief || task.title; + byId('task-source').value = task.source_ref; + taskProjectInfo(); + if (task.issue?.body_truncated) byId('task-error').textContent = 'Issue brief is truncated. Review the complete issue and finish these instructions before starting.'; +} +async function submitTask(event) { + event.preventDefault(); + if (submitPending) return; + const currentSession = session; + submitPending = true; byId('submit-task').disabled = true; byId('task-close').disabled = true; + byId('submit-task').textContent = 'Preparing repository…'; byId('task-error').textContent = ''; + try { + const input = {project: byId('task-project').value, title: byId('task-title').value, instruction: byId('task-instruction').value, source_ref: byId('task-source').value.trim(), check_preset: byId('task-preset').value, checks: byId('task-preset').value === 'go' ? '' : byId('task-checks').value}; + const job = await api('/v1/control/jobs', input); + if (currentSession !== session) return; + byId('task-modal').close(); + await refresh(); + if (currentSession === session && token) { + if (input.source_ref) { + const task = boardTasks().find(task => task.source_ref === input.source_ref && task.project === input.project); + openTaskDetail(task || {project:input.project,source_ref:input.source_ref,title:input.title,latest_run:{...job,project:input.project},run_count:1}); + } else openDetail(job.id); + } + } catch (error) { if (currentSession === session) byId('task-error').textContent = error.message; } + finally { + submitPending = false; byId('submit-task').disabled = false; byId('task-close').disabled = false; + byId('submit-task').textContent = 'Start run'; + } +} +function switchView(view) { + for (const name of ['board', 'workers', 'runs']) { + const active = view === name; + byId(`${name}-view`).hidden = !active; + byId(`${name}-tab`).className = active ? 'nav active' : 'nav'; + if (active) byId(`${name}-tab`).setAttribute('aria-current','page'); else byId(`${name}-tab`).removeAttribute('aria-current'); + } + byId('view-title').textContent = {board:'Task board',workers:'Workers',runs:'Runs history'}[view]; +} +byId('auth-form').addEventListener('submit', event => { + event.preventDefault(); + if (refreshing) return; + session++; token = byId('token').value; byId('token').value = ''; refresh(); +}); +byId('lock').addEventListener('click', () => { lockWorkspace(); byId('token').focus(); }); +byId('refresh').addEventListener('click', refresh); +byId('board-tab').addEventListener('click', () => switchView('board')); +byId('workers-tab').addEventListener('click', () => switchView('workers')); +byId('project-filter').addEventListener('change', () => selectProject(byId('project-filter').value)); +byId('backlog-refresh').addEventListener('click', () => loadBacklog(true)); +byId('runs-tab').addEventListener('click', () => switchView('runs')); +byId('run-status').addEventListener('change', renderRuns); +byId('new-task').addEventListener('click', openTask); +byId('task-project').addEventListener('change', taskProjectInfo); +byId('task-preset').addEventListener('change', () => { byId('scoped-checks').hidden = byId('task-preset').value === 'go'; byId('task-checks').required = !byId('scoped-checks').hidden; }); +byId('task-form').addEventListener('submit', submitTask); +byId('task-close').addEventListener('click', () => byId('task-modal').close()); +byId('task-modal').addEventListener('cancel', event => { if (submitPending) event.preventDefault(); }); +byId('detail-close').addEventListener('click', () => byId('detail-modal').close()); +byId('detail-modal').addEventListener('close', () => { detailEpoch++; selectedJob = ''; selectedTask = null; selectedActivity = null; }); +document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); }); +window.addEventListener('online', refresh); +window.addEventListener('offline', () => notice('Offline — displayed data may be stale.', true)); +setInterval(() => { if (!document.hidden) refresh(); }, 5000); +renderBoard(); diff --git a/internal/gate/app/index.html b/internal/gate/app/index.html new file mode 100644 index 0000000..5c0589a --- /dev/null +++ b/internal/gate/app/index.html @@ -0,0 +1,43 @@ + + + + +Agent Forge · Control panel + + + + + +
+
WORKSPACE / OPERATIONS

Task board

+

Connect your workspace

Use your owner token to see projects, workers and work.

Kept in memory for this page only. Reloading locks the workspace.
+
+

Workspace locked.

+

Select a project to load its public issue backlog.

+ + +
+ +
CREATE WORK

New coding task

+
+

+ + + +

Without an issue URL, this creates an unlinked run in Runs history. No GitHub issue is created.

+ + +
Starts from the current default branch.
+
+
+

Task details

+ + diff --git a/internal/gate/candidate_report_test.go b/internal/gate/candidate_report_test.go new file mode 100644 index 0000000..df2e106 --- /dev/null +++ b/internal/gate/candidate_report_test.go @@ -0,0 +1,91 @@ +package gate + +import ( + "agent-forge/internal/protocol" + "agent-forge/internal/store" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestGateCandidateReportPersistsOrRejectsBeforeMutation(t *testing.T) { + for _, report := range []string{`{"summary":"Normalize input","changes":["Match equivalent names"]}`, `{"summary":"private","changes":[]}`, `mixed-failure-private`} { + t.Run(report[:12], func(t *testing.T) { + s, err := store.Open(filepath.Join(secureTempDir(t), "forge.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + job, err := s.CreateCodingJob(protocol.CodingTask{Repository: "/repo", BaseSHA: strings.Repeat("a", 40), Instruction: "edit"}) + if err != nil { + t.Fatal(err) + } + options := DefaultOptions() + options.LeasePollInterval = time.Millisecond + h, err := NewHandlerWithOptions(s, map[string]string{"token": "worker-1"}, "owner", options) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + c := dialWorker(t, ts.URL, "worker-1", "token") + defer c.CloseNow() + lease := readMessage(t, c) + message := protocol.Message{Type: protocol.MessageResult, JobID: lease.JobID, AttemptID: lease.AttemptID, CandidateSHA: strings.Repeat("b", 40), Result: report} + if report == "mixed-failure-private" { + message.Error = protocol.FailureExecution + message.Disposition = protocol.DispositionRetryable + } + writeMessage(t, c, message) + ack := readMessage(t, c) + got, _ := s.Job(job.ID) + attempts, _ := s.Attempts(job.ID) + if strings.Contains(report, "private") { + if ack.Type != protocol.MessageError || ack.Error != "request failed" || got.Status != "leased" || got.Result != "" || attempts[0].Result != "" { + t.Fatal("malformed report accepted or leaked", ack, got.Status) + } + } else if ack.Type != protocol.MessageAck || got.Result != report || attempts[0].Result != report { + t.Fatalf("report discarded: ack=%s job=%q", ack.Type, got.Result) + } + }) + } +} + +func TestControlProjectsOnlyTypedCandidateAgentReports(t *testing.T) { + s, _, h := controlFixture(t) + report := `{"summary":"Normalize input","changes":["Match equivalent names"]}` + job, err := s.CreateCodingJob(protocol.CodingTask{BaseSHA: strings.Repeat("a", 40), Instruction: "edit"}) + if err != nil { + t.Fatal(err) + } + lease, ok, err := s.LeaseNext("worker") + if err != nil || !ok { + t.Fatal(err) + } + if _, err = s.CompleteCandidateReportAt(job.ID, lease.AttemptID, strings.Repeat("b", 40), report, time.Now().UTC()); err != nil { + t.Fatal(err) + } + w := controlRequest(h, "GET", "/v1/control/jobs/"+job.ID, "owner", "") + if w.Code != 200 || strings.Count(w.Body.String(), `"agent_report":`) < 2 || !strings.Contains(w.Body.String(), `"summary":"Normalize input"`) { + t.Fatal("missing typed run/attempt report", w.Body.String()) + } + for _, legacy := range []string{"private arbitrary output", `{"summary":"x","changes":[]}`, report, ""} { + job, err := s.CreateJob("legacy") + if err != nil { + t.Fatal(err) + } + lease, ok, err := s.LeaseNext("worker") + if err != nil || !ok { + t.Fatal(err) + } + if _, err = s.CompleteAt(job.ID, lease.AttemptID, legacy, time.Now().UTC()); err != nil { + t.Fatal(err) + } + w := controlRequest(h, "GET", "/v1/control/jobs/"+job.ID, "owner", "") + if w.Code != 200 || strings.Contains(w.Body.String(), `"agent_report"`) || strings.Contains(w.Body.String(), "private arbitrary") { + t.Fatal("legacy result exposed as agent report") + } + } +} diff --git a/internal/gate/control.go b/internal/gate/control.go new file mode 100644 index 0000000..a5dc4bd --- /dev/null +++ b/internal/gate/control.go @@ -0,0 +1,371 @@ +package gate + +import ( + "context" + "database/sql" + "embed" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "agent-forge/internal/configjson" + "agent-forge/internal/protocol" + "agent-forge/internal/store" +) + +//go:embed app/index.html app/app.css app/app.js +var controlFiles embed.FS + +func (x *server) controlAsset(w http.ResponseWriter, r *http.Request) { + name, mime := "app/index.html", "text/html; charset=utf-8" + switch r.URL.Path { + case "/app", "/app/": + case "/app/app.css": + name, mime = "app/app.css", "text/css; charset=utf-8" + case "/app/app.js": + name, mime = "app/app.js", "text/javascript; charset=utf-8" + default: + http.NotFound(w, r) + return + } + body, err := controlFiles.ReadFile(name) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", mime) + w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Write(body) +} + +type controlProject struct { + ID string `json:"id"` + DefaultBranch string `json:"default_branch"` + WorkerPool string `json:"worker_pool"` + Agent string `json:"agent"` + PublicSource bool `json:"public_source"` + Delivery bool `json:"delivery"` +} + +type controlJob struct { + Ordinal int `json:"ordinal,omitempty"` + PluginTimeoutMS int64 `json:"plugin_timeout_ms,omitempty"` + ID string `json:"id"` + Title string `json:"title"` + Project string `json:"project"` + Status string `json:"status"` + SourceRef string `json:"source_ref,omitempty"` + WorkerID string `json:"worker_id,omitempty"` + Agent string `json:"agent,omitempty"` + FailureCode string `json:"failure_code,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Delivery *safeDelivery `json:"delivery,omitempty"` +} + +func controlTitle(instruction string) string { + for _, line := range strings.Split(instruction, "\n") { + line = strings.TrimSpace(strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, line)) + if line != "" { + r := []rune(line) + if len(r) > 120 { + r = r[:120] + } + return string(r) + } + } + return "Untitled task" +} + +func controlURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || len(raw) > 512 || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.ContainsAny(raw, "\\\r\n\t") { + return "" + } + return u.String() +} + +func (x *server) controlCard(ctx context.Context, job store.Job) (controlJob, error) { + agent, timeout, err := x.store.ControlAgent(ctx, job.ID) + if err != nil { + return controlJob{}, err + } + instruction, project := job.Input, "" + if job.Task != nil { + instruction, project = job.Task.Instruction, job.Task.RepositoryID + } + delivery := x.safeDelivery(job.ID) + if delivery != nil { + delivery.PRURL = controlURL(delivery.PRURL) + } + return controlJob{PluginTimeoutMS: timeout, ID: job.ID, Title: controlTitle(instruction), Project: project, Status: job.Status, SourceRef: controlURL(job.SourceRef), WorkerID: job.WorkerID, Agent: agent, FailureCode: safeFailureCode(job.Error), CreatedAt: job.CreatedAt, UpdatedAt: job.UpdatedAt, Delivery: delivery}, nil +} + +func (x *server) controlOverview(w http.ResponseWriter, r *http.Request) { + projects := []controlProject{} + if x.config != nil { + for _, repo := range x.config.Repositories { + _, err := canonicalPublicGitHubURL(repo.RepositoryURL) + public := err == nil && x.config.PublicRepositoryRoot != "" && x.config.GitExecutable != "" + projects = append(projects, controlProject{repo.ID, repo.DefaultBranch, repo.WorkerPool, repo.Execution.PluginID, public, public && x.config.Delivery != nil}) + } + } + workers, truncated, err := x.store.ControlWorkers(r.Context()) + if err != nil { + writeDebugError(w, err) + return + } + // Configured slots that have never connected have no heartbeat. + // ponytail: scan at most 256 configured slots; index IDs if the slot ceiling grows. + if x.config != nil { + for _, reg := range x.config.Workers { + for slot := 0; slot < reg.Concurrency; slot++ { + id := reg.ID + if slot > 0 { + id += "#" + strconv.Itoa(slot) + } + found := false + for _, worker := range workers { + if worker.ID == id { + found = true + break + } + } + if !found { + workers = append(workers, store.ControlWorker{ID: id, BaseID: reg.ID, Slot: slot, Pool: reg.Pool}) + } + } + } + } + page, err := x.store.RecentDebugJobs(r.Context(), 100, nil) + if err != nil { + writeDebugError(w, err) + return + } + jobs := []controlJob{} + for _, recent := range page.Items { + job, err := x.store.Job(recent.ID) + if err != nil { + writeDebugError(w, err) + return + } + card, err := x.controlCard(r.Context(), job) + if err != nil { + writeDebugError(w, err) + return + } + jobs = append(jobs, card) + } + tasks, tasksTruncated, err := x.localControlTasks(r.Context()) + if err != nil { + writeDebugError(w, err) + return + } + writeJSON(w, 200, struct { + Tasks []controlTask `json:"tasks"` + TasksTruncated bool `json:"tasks_truncated"` + Projects []controlProject `json:"projects"` + Workers []store.ControlWorker `json:"workers"` + Jobs []controlJob `json:"jobs"` + JobsTruncated bool `json:"jobs_truncated"` + WorkersTruncated bool `json:"workers_truncated"` + }{tasks, tasksTruncated, projects, workers, jobs, page.NextPosition != nil, truncated}) +} + +type controlAttempt struct { + AgentReport *protocol.AgentReport `json:"agent_report,omitempty"` + safeAttempt + WorkerID string `json:"worker_id"` +} + +func (x *server) controlDetail(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !validJobID(id) { + http.NotFound(w, r) + return + } + job, err := x.store.Job(id) + if errors.Is(err, sql.ErrNoRows) { + http.NotFound(w, r) + return + } + if err != nil { + writeDebugError(w, err) + return + } + card, err := x.controlCard(r.Context(), job) + if err != nil { + writeDebugError(w, err) + return + } + attempts, truncated, err := x.store.ControlAttempts(id) + if err != nil { + writeDebugError(w, err) + return + } + safe := []controlAttempt{} + var runReport *protocol.AgentReport + for _, a := range attempts { + records, err := x.store.AttemptEvidence(id, a.ID) + if err != nil { + writeDebugError(w, err) + return + } + evidence := []safeEvidence{} + for _, record := range records { + evidence = append(evidence, safeEvidence{EvidenceID: record.EvidenceID, Phase: record.Phase, Reason: record.Reason, CheckIndex: record.CheckIndex, ExitCode: record.ExitCode, DurationMS: record.DurationMS, OutputRedacted: record.OutputRedacted, OutputTruncated: record.OutputTruncated, BaseSHA: record.BaseSHA, CandidateSHA: record.CandidateSHA, ArgvRedacted: record.ArgvRedacted}) + } + var report *protocol.AgentReport + if job.Task != nil && a.Status == "succeeded" && protocol.ValidateBaseSHA(a.CandidateSHA) == nil { + report, _ = protocol.DecodeAgentReport(a.Result) + } + if a.ID == job.AttemptID && a.CandidateSHA == job.CandidateSHA { + runReport = report + } + safe = append(safe, controlAttempt{AgentReport: report, safeAttempt: safeAttempt{ID: a.ID, Ordinal: a.Ordinal, Status: a.Status, FailureDisposition: a.FailureDisposition, FailureCode: safeFailureCode(a.FailureCode), CandidateSHA: a.CandidateSHA, LeasedAt: a.LeasedAt, DeadlineAt: a.DeadlineAt, CompletedAt: a.CompletedAt, Evidence: evidence}, WorkerID: a.WorkerID}) + } + timeline, err := x.store.DebugJobTimeline(r.Context(), id, 100, nil) + if err != nil { + writeDebugError(w, err) + return + } + events := []store.DebugEvent{} + for _, event := range timeline.Events { + switch event.Type { + case "submitted", "leased", "lease_expired", "retryable_failed", "retry_scheduled", "failed", "succeeded", "delivery_pending", "delivery_phase", "delivery_retry", "delivery_merged", "delivery_failed": + default: + continue + } + if !validJobID(event.AttemptID) { + event.AttemptID = "" + } + events = append(events, event) + } + instruction, base, checks := job.Input, "", 0 + if job.Task != nil { + instruction, base, checks = job.Task.Instruction, job.Task.BaseSHA, len(job.Task.Tests) + } + body, err := json.Marshal(struct { + Job controlJob `json:"job"` + Instruction string `json:"instruction"` + CheckCount int `json:"check_count"` + Attempts []controlAttempt `json:"attempts"` + AttemptsTruncated bool `json:"attempts_truncated"` + Timeline []store.DebugEvent `json:"timeline"` + TimelineTruncated bool `json:"timeline_truncated"` + Delivery *safeDelivery `json:"delivery,omitempty"` + Diagnostics map[string]string `json:"diagnostics"` + SubagentTelemetry string `json:"subagent_telemetry"` + AgentReport *protocol.AgentReport `json:"agent_report,omitempty"` + }{card, instruction, checks, safe, truncated, events, timeline.NextPosition != nil, card.Delivery, map[string]string{"id": id, "attempt_id": job.AttemptID, "base_sha": base, "candidate_sha": job.CandidateSHA}, "No subagent telemetry reported for this run", runReport}) + if err != nil || len(body) > protocol.MaxWorkerMessageBytes { + writeResultError(w, http.StatusRequestEntityTooLarge, CodeResultTooLarge, "detail exceeds limit") + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(body) +} +func (x *server) controlSubmit(w http.ResponseWriter, r *http.Request) { + invalid := func() { + writeJSON(w, 400, map[string]string{"error": "invalid task: choose a project, title, instruction and checks"}) + } + var in struct { + Project string `json:"project"` + Title string `json:"title"` + Instruction string `json:"instruction"` + SourceRef string `json:"source_ref"` + CheckPreset string `json:"check_preset"` + Checks string `json:"checks"` + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 128<<10)) + if err != nil || configjson.Decode(body, &in) != nil || x.config == nil { + invalid() + return + } + in.Title = strings.TrimSpace(in.Title) + in.Instruction = strings.TrimSpace(in.Instruction) + in.SourceRef = strings.TrimSpace(in.SourceRef) + if in.Title == "" || len([]rune(in.Title)) > 120 || strings.IndexFunc(in.Title, unicode.IsControl) >= 0 || in.Instruction == "" || !utf8.ValidString(in.Title+in.Instruction) || in.SourceRef != "" && !exactGitHubIssueURL(in.SourceRef) { + invalid() + return + } + repository, ok := x.repository(in.Project) + if !ok { + invalid() + return + } + if in.SourceRef != "" && !projectIssueSource(repository.RepositoryURL, in.SourceRef) { + invalid() + return + } + checks := [][]string{} + switch in.CheckPreset { + case "go": + if strings.TrimSpace(in.Checks) != "" { + invalid() + return + } + checks = append(checks, []string{"go", "test", "./..."}) + case "": + for _, line := range strings.Split(in.Checks, "\n") { + argv := strings.Fields(line) + if len(argv) == 0 { + continue + } + // Explicit checks are argv lines, not shell scripts; quoting is intentionally unsupported. + if strings.ContainsAny(line, "\"'`\x00") { + invalid() + return + } + for _, arg := range argv { + if len(arg) > 4096 { + invalid() + return + } + } + checks = append(checks, argv) + } + if len(checks) == 0 { + invalid() + return + } + default: + invalid() + return + } + task := protocol.CodingTask{RepositoryID: in.Project, BaseSHA: strings.Repeat("0", 40), Instruction: in.Title + "\n\n" + in.Instruction, Tests: checks} + if validateTask(task) != nil { + invalid() + return + } + if _, err := canonicalPublicGitHubURL(repository.RepositoryURL); err != nil || x.config.PublicRepositoryRoot == "" || x.config.GitExecutable == "" { + writeJSON(w, 422, map[string]string{"error": "public source preparation is unavailable for this project"}) + return + } + task.Repository, task.BaseSHA, err = preparePublicRepository(r.Context(), *x.config, repository, "") + if err != nil { + status := http.StatusUnprocessableEntity + var preparation preparationError + if errors.As(err, &preparation) && preparation.retryable { + status = http.StatusBadGateway + } + writeJSON(w, status, map[string]string{"error": "repository preparation failed"}) + return + } + x.persistConfiguredTask(w, task, repository, in.SourceRef) +} diff --git a/internal/gate/control_activity.go b/internal/gate/control_activity.go new file mode 100644 index 0000000..b9c667b --- /dev/null +++ b/internal/gate/control_activity.go @@ -0,0 +1,243 @@ +package gate + +import ( + "context" + "encoding/json" + "net/http" + "path" + "regexp" + "strconv" + "strings" + "time" +) + +var githubActor = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]{0,38}(\[bot\])?$`) + +func issueClosedAt(state, value string) string { + if state != "closed" { + return "" + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil || parsed.IsZero() { + return "" + } + return parsed.UTC().Format(time.RFC3339) +} +func issueClosedBy(state, value string) string { + if state != "closed" || !githubActor.MatchString(value) { + return "" + } + return value +} + +type controlMergedPR struct { + Number int64 `json:"number"` + URL string `json:"url"` + MergedAt string `json:"merged_at"` + Details *controlPRDetails `json:"details,omitempty"` +} + +func (x *server) controlIssueActivity(w http.ResponseWriter, r *http.Request) { + unavailable := func(status int) { + writeJSON(w, status, map[string]any{"available": false, "error": "public issue activity unavailable"}) + } + number, err := strconv.ParseInt(r.PathValue("number"), 10, 64) + if err != nil || number < 1 || number > 9007199254740991 || strconv.FormatInt(number, 10) != r.PathValue("number") { + unavailable(400) + return + } + if x.config == nil { + http.NotFound(w, r) + return + } + repository, ok := x.repository(r.PathValue("id")) + if !ok { + http.NotFound(w, r) + return + } + source, err := canonicalPublicGitHubURL(repository.RepositoryURL) + if err != nil { + unavailable(422) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + path := "/repos/" + source.Owner + "/" + source.Repository + "/issues/" + strconv.FormatInt(number, 10) + canonical := "https://github.com/" + source.Owner + "/" + source.Repository + body, err := fetchControlGitHub(ctx, path) + if err != nil { + unavailable(502) + return + } + var raw struct { + Number int64 `json:"number"` + URL string `json:"html_url"` + State string `json:"state"` + ClosedAt string `json:"closed_at"` + ClosedBy struct { + Login string `json:"login"` + } `json:"closed_by"` + PullRequest json.RawMessage `json:"pull_request"` + } + issueURL := canonical + "/issues/" + strconv.FormatInt(number, 10) + if json.Unmarshal(body, &raw) != nil || raw.Number != number || raw.URL != issueURL || len(raw.PullRequest) != 0 || (raw.State != "closed" && raw.State != "open") { + unavailable(502) + return + } + issue := controlIssue{Number: int(number), URL: issueURL, State: raw.State, ClosedAt: issueClosedAt(raw.State, raw.ClosedAt), ClosedBy: issueClosedBy(raw.State, raw.ClosedBy.Login)} + body, err = fetchControlGitHub(ctx, path+"/timeline?per_page=50") + if err != nil { + unavailable(502) + return + } + var events []json.RawMessage + if json.Unmarshal(body, &events) != nil || events == nil { + unavailable(502) + return + } + truncated := len(events) >= maxProjectIssues + if len(events) > maxProjectIssues { + events = events[:maxProjectIssues] + } + prs := []controlMergedPR{} + seen := map[string]bool{} + for _, record := range events { + var event struct { + Event string `json:"event"` + Source struct { + Type string `json:"type"` + Issue struct { + Number int64 `json:"number"` + URL string `json:"html_url"` + PullRequest struct { + URL string `json:"html_url"` + MergedAt string `json:"merged_at"` + } `json:"pull_request"` + } `json:"issue"` + } `json:"source"` + } + if json.Unmarshal(record, &event) != nil { + continue + } + pr := event.Source.Issue + if event.Event != "cross-referenced" || event.Source.Type != "issue" || pr.Number < 1 || pr.Number > 9007199254740991 { + continue + } + url := canonical + "/pull/" + strconv.FormatInt(pr.Number, 10) + merged := issueClosedAt("closed", pr.PullRequest.MergedAt) + if pr.URL != url || pr.PullRequest.URL != url || merged == "" || seen[url] { + continue + } + seen[url] = true + if len(prs) == 3 { + truncated = true + continue + } + prs = append(prs, controlMergedPR{Number: pr.Number, URL: url, MergedAt: merged}) + } + for i := range prs { + prs[i].Details = fetchControlPRDetails(ctx, source, prs[i], issue, repository.DefaultBranch) + } + writeJSON(w, 200, map[string]any{"available": true, "issue": issue, "merged_prs": prs, "truncated": truncated}) +} + +type controlPRFile struct { + Filename string `json:"filename"` + Status string `json:"status"` + Additions int64 `json:"additions"` + Deletions int64 `json:"deletions"` +} +type controlPRDetails struct { + DeliveryEvidence bool `json:"delivery_evidence"` + Title string `json:"title"` + Summary string `json:"summary"` + SummaryTruncated bool `json:"summary_truncated"` + ChangedFiles int64 `json:"changed_files"` + Additions int64 `json:"additions"` + Deletions int64 `json:"deletions"` + Files []controlPRFile `json:"files"` + FilesTruncated bool `json:"files_truncated"` +} + +func validPRCount(n *int64) bool { return n != nil && *n >= 0 && *n <= 9007199254740991 } +func fetchControlPRDetails(ctx context.Context, source publicSource, pr controlMergedPR, issue controlIssue, defaultBranch string) *controlPRDetails { + endpoint := "/repos/" + source.Owner + "/" + source.Repository + "/pulls/" + strconv.FormatInt(pr.Number, 10) + body, err := fetchControlGitHub(ctx, endpoint) + if err != nil { + return nil + } + var raw struct { + Number int64 `json:"number"` + URL string `json:"html_url"` + Title string `json:"title"` + Body string `json:"body"` + MergedAt string `json:"merged_at"` + Base struct { + Ref string `json:"ref"` + Repo struct { + URL string `json:"html_url"` + } `json:"repo"` + } `json:"base"` + ChangedFiles *int64 `json:"changed_files"` + Additions *int64 `json:"additions"` + Deletions *int64 `json:"deletions"` + } + if json.Unmarshal(body, &raw) != nil || raw.Number != pr.Number || raw.URL != pr.URL || issueClosedAt("closed", raw.MergedAt) != pr.MergedAt || !validPRCount(raw.ChangedFiles) || !validPRCount(raw.Additions) || !validPRCount(raw.Deletions) { + return nil + } + title := boundedIssueText(raw.Title, 120, false) + if title == "" { + return nil + } + detail := &controlPRDetails{Title: title, Summary: boundedIssueText(raw.Body, 2000, true), SummaryTruncated: len([]rune(raw.Body)) > 2000, ChangedFiles: *raw.ChangedFiles, Additions: *raw.Additions, Deletions: *raw.Deletions, Files: []controlPRFile{}} + merged, mergeErr := time.Parse(time.RFC3339, pr.MergedAt) + closed, closeErr := time.Parse(time.RFC3339, issue.ClosedAt) + // Closing evidence requires closure within five minutes of a default-branch merge. + detail.DeliveryEvidence = issue.State == "closed" && mergeErr == nil && closeErr == nil && + !closed.Before(merged) && closed.Sub(merged) <= 5*time.Minute && defaultBranch != "" && + raw.Base.Ref == defaultBranch && raw.Base.Repo.URL == "https://github.com/"+source.Owner+"/"+source.Repository && + explicitIssueClosure(raw.Body, source, issue.Number) + body, err = fetchControlGitHub(ctx, endpoint+"/files?per_page=20") + if err != nil { + return nil + } + var records []json.RawMessage + if json.Unmarshal(body, &records) != nil || records == nil { + return nil + } + detail.FilesTruncated = len(records) > 20 || detail.ChangedFiles > int64(len(records)) + if len(records) > 20 { + records = records[:20] + } + for _, record := range records { + var f struct { + Filename string `json:"filename"` + Status string `json:"status"` + Additions *int64 `json:"additions"` + Deletions *int64 `json:"deletions"` + } + if json.Unmarshal(record, &f) != nil || !validPRCount(f.Additions) || !validPRCount(f.Deletions) || f.Filename == "" || f.Filename == "." || len(f.Filename) > 256 || boundedIssueText(f.Filename, 256, false) != f.Filename || path.IsAbs(f.Filename) || path.Clean(f.Filename) != f.Filename || strings.HasPrefix(f.Filename, "../") || strings.ContainsAny(f.Filename, "\\:") { + detail.FilesTruncated = true + continue + } + switch f.Status { + case "added", "removed", "modified", "renamed", "copied", "changed", "unchanged": + default: + detail.FilesTruncated = true + continue + } + detail.Files = append(detail.Files, controlPRFile{Filename: f.Filename, Status: f.Status, Additions: *f.Additions, Deletions: *f.Deletions}) + } + return detail +} + +// Only standalone closing clauses are proof. Ambiguous Markdown/HTML/quotations +// anywhere in the body conservatively leave the PR related, never delivered. +func explicitIssueClosure(body string, source publicSource, number int) bool { + if strings.ContainsAny(body, "`~<>\"'“”‘’") { + return false + } + ref := "#" + strconv.Itoa(number) + clause := regexp.MustCompile(`(?m)^(?i:close|closed|closes|fix|fixed|fixes|resolve|resolved|resolves)[ \t]+(?:` + regexp.QuoteMeta(ref) + `|` + regexp.QuoteMeta(source.Owner+"/"+source.Repository+ref) + `)[ \t]*\.?[ \t]*$`) + return clause.MatchString(body) +} diff --git a/internal/gate/control_delivery_evidence_test.go b/internal/gate/control_delivery_evidence_test.go new file mode 100644 index 0000000..5431b4f --- /dev/null +++ b/internal/gate/control_delivery_evidence_test.go @@ -0,0 +1,240 @@ +package gate + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" +) + +const closedIssueFixture = `{"number":53,"html_url":"https://github.com/0k-lab/agent-forge/issues/53","state":"closed","closed_at":"2026-08-29T20:44:18Z"}` +const mergedTimelineFixture = `[{"event":"cross-referenced","source":{"type":"issue","issue":{"number":55,"html_url":"https://github.com/0k-lab/agent-forge/pull/55","pull_request":{"html_url":"https://github.com/0k-lab/agent-forge/pull/55","merged_at":"2026-08-29T20:44:17Z"}}}}]` +const prDetailFixture = `{"number":55,"html_url":"https://github.com/0k-lab/agent-forge/pull/55","title":"Preserve Unicode","body":"Normalize Unicode input so equivalent names match.","merged_at":"2026-08-29T20:44:17Z","changed_files":2,"additions":18,"deletions":3,"token":"private-secret"}` +const prFilesFixture = `[{"filename":"parser/normalize.go","status":"modified","additions":12,"deletions":3,"patch":"private-patch"},{"filename":"parser/normalize_test.go","status":"added","additions":6,"deletions":0}]` + +func TestControlDeliveredChanges(t *testing.T) { + _, _, h := controlFixture(t) + calls := 0 + mockIssues(t, func(r *http.Request) (*http.Response, error) { + calls++ + if r.Method != "GET" || r.URL.Host != "api.github.com" || r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { + t.Fatal("unsafe request") + } + switch r.URL.Path { + case "/repos/0k-lab/agent-forge/issues/53": + return issueResponse(200, closedIssueFixture), nil + case "/repos/0k-lab/agent-forge/issues/53/timeline": + return issueResponse(200, mergedTimelineFixture), nil + case "/repos/0k-lab/agent-forge/pulls/55": + return issueResponse(200, prDetailFixture), nil + case "/repos/0k-lab/agent-forge/pulls/55/files": + if r.URL.Query().Get("per_page") != "20" { + t.Fatal("unbounded files") + } + return issueResponse(200, prFilesFixture), nil + default: + t.Fatal("unexpected request", r.URL) + return nil, nil + } + }) + w := controlRequest(h, "GET", "/v1/control/projects/agent-forge/issues/53/activity", "owner", "") + var got struct { + MergedPRs []struct { + Details struct { + Title, Summary string + ChangedFiles int `json:"changed_files"` + Files []struct{ Filename string } + } + } `json:"merged_prs"` + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got.MergedPRs) != 1 || got.MergedPRs[0].Details.Title != "Preserve Unicode" || got.MergedPRs[0].Details.ChangedFiles != 2 || len(got.MergedPRs[0].Details.Files) != 2 || calls != 4 { + t.Fatalf("missing delivered changes: %d %s (%d requests)", w.Code, w.Body.String(), calls) + } + if !strings.Contains(got.MergedPRs[0].Details.Summary, "equivalent names match") || got.MergedPRs[0].Details.Files[1].Filename != "parser/normalize_test.go" { + t.Fatal("missing explanation/files") + } + for _, bad := range []string{"private-secret", "private-patch"} { + if strings.Contains(w.Body.String(), bad) { + t.Fatal("leak") + } + } +} + +func TestControlDeliveredChangesFailureAndBounds(t *testing.T) { + _, _, h := controlFixture(t) + for _, mode := range []string{"redirect", "oversize", "bad-count", "wrong-url", "files-failure", "sanitized"} { + t.Run(mode, func(t *testing.T) { + calls := 0 + mockIssues(t, func(r *http.Request) (*http.Response, error) { + calls++ + if r.URL.Host != "api.github.com" || r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { + t.Fatal("credentials or redirect") + } + if strings.HasSuffix(r.URL.Path, "/issues/53") { + return issueResponse(200, closedIssueFixture), nil + } + if strings.HasSuffix(r.URL.Path, "/timeline") { + return issueResponse(200, mergedTimelineFixture), nil + } + if strings.HasSuffix(r.URL.Path, "/files") { + if mode == "files-failure" { + return issueResponse(429, "private-secret"), nil + } + files := strings.TrimSuffix(prFilesFixture, "]") + `,{"filename":"../private","status":"added","additions":1,"deletions":0},{"filename":"bad\u202ename","status":"added","additions":1,"deletions":0}]` + return issueResponse(200, files), nil + } + switch mode { + case "redirect": + response := issueResponse(302, "") + response.Header.Set("Location", "https://evil.example/private") + return response, nil + case "oversize": + return issueResponse(200, strings.Repeat("x", (1<<20)+1)), nil + case "bad-count": + return issueResponse(200, strings.Replace(prDetailFixture, `"changed_files":2`, `"changed_files":-1`, 1)), nil + case "wrong-url": + return issueResponse(200, strings.ReplaceAll(prDetailFixture, "https://github.com", "https://secret@github.com")), nil + default: + return issueResponse(200, prDetailFixture), nil + } + }) + w := controlRequest(h, "GET", "/v1/control/projects/agent-forge/issues/53/activity", "owner", "") + if w.Code != 200 || !strings.Contains(w.Body.String(), `"merged_at":"2026-08-29T20:44:17Z"`) { + t.Fatal("lost basic evidence", w.Body.String()) + } + if mode == "sanitized" { + if !strings.Contains(w.Body.String(), `"files_truncated":true`) || strings.Contains(w.Body.String(), "bad") { + t.Fatal("unsafe file", w.Body.String()) + } + } else if strings.Contains(w.Body.String(), `"details"`) { + t.Fatal("invalid detail accepted") + } + if calls > 4 || strings.Contains(w.Body.String(), "private") || strings.Contains(w.Body.String(), "secret@") { + t.Fatal("unbounded/unsafe response") + } + }) + } +} + +func TestControlDeliveredChangesCapsPRsFilesAndSummary(t *testing.T) { + _, _, h := controlFixture(t) + calls := 0 + mockIssues(t, func(r *http.Request) (*http.Response, error) { + calls++ + if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { + t.Fatal("auth forwarded") + } + if strings.HasSuffix(r.URL.Path, "/issues/53") { + return issueResponse(200, closedIssueFixture), nil + } + if strings.HasSuffix(r.URL.Path, "/timeline") { + records := []string{} + for _, n := range []string{"55", "56", "57", "58"} { + records = append(records, strings.TrimSuffix(strings.TrimPrefix(strings.ReplaceAll(mergedTimelineFixture, "55", n), "["), "]")) + } + return issueResponse(200, "["+strings.Join(records, ",")+"]"), nil + } + if strings.HasSuffix(r.URL.Path, "/files") { + var files []map[string]any + for i := 0; i < 25; i++ { + files = append(files, map[string]any{"filename": fmt.Sprintf("src/file%d.go", i), "status": "modified", "additions": 1, "deletions": 0}) + } + body, _ := json.Marshal(files) + return issueResponse(200, string(body)), nil + } + parts := strings.Split(r.URL.Path, "/") + number := parts[len(parts)-1] + body := strings.ReplaceAll(prDetailFixture, "55", number) + body = strings.Replace(body, `"changed_files":2`, `"changed_files":25`, 1) + body = strings.Replace(body, "Normalize Unicode input so equivalent names match.", strings.Repeat("x", 2100), 1) + return issueResponse(200, body), nil + }) + w := controlRequest(h, "GET", "/v1/control/projects/agent-forge/issues/53/activity", "owner", "") + var got struct { + MergedPRs []controlMergedPR `json:"merged_prs"` + Truncated bool + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got.MergedPRs) != 3 || calls != 8 || !got.Truncated { + t.Fatal("PR bound", w.Code, calls, w.Body.String()) + } + for _, pr := range got.MergedPRs { + if pr.Details == nil || len(pr.Details.Files) != 20 || !pr.Details.FilesTruncated || len(pr.Details.Summary) != 2000 || !pr.Details.SummaryTruncated { + t.Fatal("missing bounds", pr) + } + } +} + +func TestCrossReferenceRequiresStrictClosingProof(t *testing.T) { + for _, tc := range []struct { + name, body, branch, repo, state, closed string + want bool + }{ + {"unrelated", "Improve docs", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"plain mention", "#53", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"wrong number", "Closes #530", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"other issue", "Fixes #54", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"other repo", "Closes other/repo#53", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"inline", "`Closes #53`", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"inline ref", "Closes `#53`", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"fence", "```text\nCloses #53\n```", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"tilde fence", "~~~\nCloses #53\n~~~", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"quote", "> Closes #53", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"quotation", "\"Closes #53\"", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"indented code", " Closes #53", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"ordinary", "This mentions #53 but does not fix it", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"branch", "Closes #53", "develop", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", false}, + {"base repo", "Closes #53", "main", "other/repo", "closed", "2026-08-29T20:44:18Z", false}, + {"open", "Closes #53", "main", "0k-lab/agent-forge", "open", "2026-08-29T20:44:18Z", false}, + {"after closure", "Closes #53", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:16Z", false}, + {"too late", "Closes #53", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:49:18Z", false}, + {"missing time", "Closes #53", "main", "0k-lab/agent-forge", "closed", "", false}, + {"exact", "Closes #53", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", true}, + {"canonical", "Fixes 0k-lab/agent-forge#53", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:44:18Z", true}, + {"window boundary", "Resolves #53", "main", "0k-lab/agent-forge", "closed", "2026-08-29T20:49:17Z", true}, + } { + t.Run(tc.name, func(t *testing.T) { + _, _, h := controlFixture(t) + mockIssues(t, func(r *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(r.URL.Path, "/issues/53"): + b, _ := json.Marshal(map[string]any{"number": 53, "html_url": "https://github.com/0k-lab/agent-forge/issues/53", "state": tc.state, "closed_at": tc.closed}) + return issueResponse(200, string(b)), nil + case strings.HasSuffix(r.URL.Path, "/timeline"): + return issueResponse(200, mergedTimelineFixture), nil + case strings.HasSuffix(r.URL.Path, "/files"): + return issueResponse(200, prFilesFixture), nil + default: + var raw map[string]any + json.Unmarshal([]byte(prDetailFixture), &raw) + raw["body"] = tc.body + raw["base"] = map[string]any{"ref": tc.branch, "repo": map[string]string{"html_url": "https://github.com/" + tc.repo}} + b, _ := json.Marshal(raw) + return issueResponse(200, string(b)), nil + } + }) + w := controlRequest(h, "GET", "/v1/control/projects/agent-forge/issues/53/activity", "owner", "") + var got struct { + PRs []struct{ Details map[string]any } `json:"merged_prs"` + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got.PRs) != 1 || got.PRs[0].Details == nil { + t.Fatalf("lost related details %s", w.Body.String()) + } + proof, ok := got.PRs[0].Details["delivery_evidence"].(bool) + if !ok || proof != tc.want { + t.Fatalf("delivery_evidence=%v present=%v want=%v", proof, ok, tc.want) + } + }) + } +} + +func TestExplicitIssueClosingKeywords(t *testing.T) { + source := publicSource{Owner: "0k-lab", Repository: "agent-forge"} + for _, keyword := range []string{"close", "closed", "closes", "fix", "fixed", "fixes", "resolve", "resolved", "resolves"} { + for _, ref := range []string{"#53", "0k-lab/agent-forge#53"} { + if !explicitIssueClosure("Description.\n\n"+strings.ToUpper(keyword)+" "+ref+".\n", source, 53) { + t.Fatalf("closing keyword rejected: %s %s", keyword, ref) + } + } + } +} diff --git a/internal/gate/control_tasks.go b/internal/gate/control_tasks.go new file mode 100644 index 0000000..92970a8 --- /dev/null +++ b/internal/gate/control_tasks.go @@ -0,0 +1,334 @@ +package gate + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode" + + "agent-forge/internal/store" +) + +const maxProjectIssues = 50 + +type controlIssue struct { + Number int `json:"number"` + URL string `json:"url"` + Title string `json:"title"` + Body string `json:"body"` + BodyTruncated bool `json:"body_truncated"` + State string `json:"state"` + Labels []string `json:"labels"` + ClosedAt string `json:"closed_at,omitempty"` + ClosedBy string `json:"closed_by,omitempty"` +} + +func boundedIssueText(value string, limit int, multiline bool) string { + value = strings.TrimSpace(strings.Map(func(r rune) rune { + if unicode.Is(unicode.Cf, r) || unicode.IsControl(r) && !(multiline && (r == '\n' || r == '\t')) { + return -1 + } + return r + }, value)) + runes := []rune(value) + if len(runes) > limit { + runes = runes[:limit] + } + return string(runes) +} + +// Public issues use a fixed GitHub origin, no authorization, and no redirects. +func fetchControlGitHub(ctx context.Context, path string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com"+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "Agent-Forge-Control") + client := http.Client{Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + response, err := client.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, errors.New("backlog unavailable") + } + body, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1)) + if err != nil || len(body) > 1<<20 { + return nil, errors.New("backlog exceeds limit") + } + return body, nil +} + +func fetchControlIssues(ctx context.Context, source publicSource) ([]controlIssue, bool, error) { + body, err := fetchControlGitHub(ctx, "/repos/"+source.Owner+"/"+source.Repository+"/issues?state=all&sort=updated&direction=desc&per_page=50") + if err != nil { + return nil, false, err + } + var raw []struct { + Number int64 `json:"number"` + URL string `json:"html_url"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + ClosedAt string `json:"closed_at"` + ClosedBy struct { + Login string `json:"login"` + } `json:"closed_by"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + PullRequest json.RawMessage `json:"pull_request"` + } + if json.Unmarshal(body, &raw) != nil || raw == nil { + return nil, false, errors.New("invalid backlog") + } + truncated := len(raw) >= maxProjectIssues + if len(raw) > maxProjectIssues { + raw = raw[:maxProjectIssues] + } + issues := []controlIssue{} + seen := map[int64]bool{} + for _, item := range raw { + if len(item.PullRequest) != 0 || item.Number < 1 || item.Number > 9007199254740991 || seen[item.Number] || item.State != "open" && item.State != "closed" { + continue + } + canonical := "https://github.com/" + source.Owner + "/" + source.Repository + "/issues/" + strconv.FormatInt(item.Number, 10) + if item.URL != canonical { + continue + } + title := boundedIssueText(item.Title, 120, false) + if title == "" { + continue + } + labels := []string{} + for _, reserved := range []string{"blocked", "ready-for-agent"} { + for _, label := range item.Labels { + if label.Name == reserved { + labels = append(labels, reserved) + break + } + } + } + for _, label := range item.Labels { + if label.Name == "blocked" || label.Name == "ready-for-agent" { + continue + } + if len(labels) == 20 { + break + } + name := boundedIssueText(label.Name, 50, false) + // Do not turn malformed labels into scheduling instructions. + if name != "" && name == label.Name { + labels = append(labels, name) + } + } + issues = append(issues, controlIssue{Number: int(item.Number), URL: canonical, Title: title, Body: boundedIssueText(item.Body, 8000, true), BodyTruncated: len([]rune(item.Body)) > 8000, State: item.State, Labels: labels, ClosedAt: issueClosedAt(item.State, item.ClosedAt), ClosedBy: issueClosedBy(item.State, item.ClosedBy.Login)}) + seen[item.Number] = true + } + return issues, truncated, nil +} + +func (x *server) controlIssues(w http.ResponseWriter, r *http.Request) { + if x.config == nil { + http.NotFound(w, r) + return + } + repository, ok := x.repository(r.PathValue("id")) + if !ok { + http.NotFound(w, r) + return + } + source, err := canonicalPublicGitHubURL(repository.RepositoryURL) + if err != nil { + writeJSON(w, 422, map[string]any{"available": false, "error": "public backlog unavailable"}) + return + } + issues, truncated, err := fetchControlIssues(r.Context(), source) + if err != nil { + writeJSON(w, 502, map[string]any{"available": false, "error": "public backlog unavailable"}) + return + } + tasks := []controlTask{} + for i := range issues { + issue := &issues[i] + refs, err := x.store.ControlSourceRuns(r.Context(), repository.ID, issue.URL) + if err != nil { + writeDebugError(w, err) + return + } + task := controlTask{SourceRef: issue.URL, Project: repository.ID, Title: issue.Title, Issue: issue} + if len(refs) > 0 { + ref := refs[len(refs)-1] + run, err := x.controlRun(r.Context(), ref) + if err != nil { + writeDebugError(w, err) + return + } + task.LatestRun = &run + task.RunCount = ref.Count + } + task.Lane = controlTaskLane(issue, task.LatestRun) + tasks = append(tasks, task) + } + payload := struct { + Tasks []controlTask `json:"tasks"` + Available bool `json:"available"` + Issues []controlIssue `json:"issues"` + Truncated bool `json:"truncated"` + }{tasks, true, issues, truncated} + body, err := json.Marshal(payload) + if err != nil || len(body) > 1<<20 { + writeJSON(w, 502, map[string]any{"available": false, "error": "public backlog exceeds response limit"}) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(body) +} + +// Closed issues and explicit blocking labels take precedence over run state. +func controlTaskLane(issue *controlIssue, run *controlJob) string { + if issue != nil { + if issue.State == "closed" { + return "Done" + } + for _, label := range issue.Labels { + if label == "blocked" { + return "Blocked" + } + } + } + if run != nil { + switch run.Status { + case "pending", "retry_wait": + return "Ready" + case "leased": + return "Working" + case "delivering": + return "Review & CI" + case "succeeded": + return "Done" + case "failed": + return "Blocked" + } + } + if issue != nil { + for _, label := range issue.Labels { + if label == "ready-for-agent" { + return "Ready" + } + } + } + return "Backlog" +} + +type controlTask struct { + SourceRef string `json:"source_ref"` + Project string `json:"project"` + Title string `json:"title"` + Lane string `json:"lane"` + Issue *controlIssue `json:"issue,omitempty"` + LatestRun *controlJob `json:"latest_run,omitempty"` + RunCount int `json:"run_count"` +} + +func (x *server) controlRun(ctx context.Context, ref store.ControlRunRef) (controlJob, error) { + job, err := x.store.Job(ref.ID) + if err != nil { + return controlJob{}, err + } + run, err := x.controlCard(ctx, job) + run.Ordinal = ref.Ordinal + return run, err +} + +func (x *server) localControlTasks(ctx context.Context) ([]controlTask, bool, error) { + refs, truncated, err := x.store.ControlTaskRuns(ctx, func(project, source string) bool { + if x.config == nil { + return false + } + repository, ok := x.repository(project) + return ok && projectIssueSource(repository.RepositoryURL, source) + }) + if err != nil { + return nil, false, err + } + tasks := []controlTask{} + for _, ref := range refs { + run, err := x.controlRun(ctx, ref) + if err != nil { + return nil, false, err + } + tasks = append(tasks, controlTask{SourceRef: run.SourceRef, Project: run.Project, Title: run.Title, Lane: controlTaskLane(nil, &run), LatestRun: &run, RunCount: ref.Count}) + } + return tasks, truncated, nil +} + +func (x *server) controlSourceRuns(w http.ResponseWriter, r *http.Request) { + project := r.PathValue("id") + // Run history survives repository removal; _unassigned cannot be a configured ID. + if project == "_unassigned" { + project = "" + } else if !configID.MatchString(project) { + http.NotFound(w, r) + return + } + source := r.URL.Query().Get("source_ref") + if source == "" || controlURL(source) != source { + writeJSON(w, 400, map[string]string{"error": "invalid source reference"}) + return + } + refs, err := x.store.ControlSourceRuns(r.Context(), project, source) + if err != nil { + writeDebugError(w, err) + return + } + runs := []controlJob{} + total := 0 + for _, ref := range refs { + run, err := x.controlRun(r.Context(), ref) + if err != nil { + writeDebugError(w, err) + return + } + runs = append(runs, run) + total = ref.Count + } + writeJSON(w, 200, struct { + Runs []controlJob `json:"runs"` + Total int `json:"total"` + Truncated bool `json:"truncated"` + }{runs, total, total > len(runs)}) +} + +func exactGitHubIssueURL(raw string) bool { + if raw == "" || controlURL(raw) != raw { + return false + } + u, err := url.Parse(raw) + if err != nil || u.Host != "github.com" { + return false + } + parts := strings.Split(u.Path, "/") + if len(parts) != 5 || parts[3] != "issues" { + return false + } + source := publicSource{Owner: parts[1], Repository: parts[2]} + number, err := strconv.ParseInt(parts[4], 10, 64) + return source.Validate() == nil && err == nil && number > 0 && number <= 9007199254740991 && strconv.FormatInt(number, 10) == parts[4] && u.EscapedPath() == u.Path +} + +func projectIssueSource(repositoryURL, sourceRef string) bool { + source, err := canonicalPublicGitHubURL(repositoryURL) + return err == nil && exactGitHubIssueURL(sourceRef) && strings.HasPrefix(sourceRef, "https://github.com/"+source.Owner+"/"+source.Repository+"/issues/") +} diff --git a/internal/gate/control_tasks_test.go b/internal/gate/control_tasks_test.go new file mode 100644 index 0000000..c2ab7c0 --- /dev/null +++ b/internal/gate/control_tasks_test.go @@ -0,0 +1,551 @@ +package gate + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "agent-forge/internal/protocol" + "agent-forge/internal/store" +) + +type issueTransport func(*http.Request) (*http.Response, error) + +func (f issueTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func issueResponse(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body))} +} +func mockIssues(t *testing.T, f issueTransport) { + t.Helper() + old := http.DefaultTransport + http.DefaultTransport = f + t.Cleanup(func() { http.DefaultTransport = old }) +} + +func TestControlPublicIssuesReadOnlyAndSanitized(t *testing.T) { + _, _, h := controlFixture(t) + calls := 0 + mockIssues(t, func(r *http.Request) (*http.Response, error) { + calls++ + if r.Method != "GET" || r.URL.Host != "api.github.com" || r.URL.Path != "/repos/0k-lab/agent-forge/issues" || r.URL.Query().Get("per_page") != "50" || r.URL.Query().Get("state") != "all" || r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { + t.Fatalf("unsafe upstream %s %v", r.URL, r.Header) + } + deadline, ok := r.Context().Deadline() + if !ok || time.Until(deadline) > 5*time.Second { + t.Fatal("missing bounded timeout") + } + return issueResponse(200, `[ + {"number":12,"html_url":"https://github.com/0k-lab/agent-forge/issues/12","title":" Fix parser ","body":"Public issue brief","state":"open","labels":[{"name":"ready-for-agent"},{"name":"bug"}],"user":{"token":"private-upstream-token"},"path":"/private/upstream"}, + {"number":13,"html_url":"https://github.com/0k-lab/agent-forge/pull/13","title":"A pull request","state":"open","pull_request":{}}, + {"number":14,"html_url":"https://secret@github.com/0k-lab/agent-forge/issues/14","title":"Credential link","state":"open"}, + {"number":15,"html_url":"https://github.com/other/repo/issues/15","title":"Wrong repository","state":"open"}, + {"number":16,"html_url":"https://github.com/0k-lab/agent-forge/issues/16","title":"Closed issue","state":"closed","labels":[]} + ]`), nil + }) + path := "/v1/control/projects/agent-forge/issues" + if w := controlRequest(h, "GET", path, "", ""); w.Code != 401 { + t.Fatalf("auth %d", w.Code) + } + if calls != 0 { + t.Fatal("unauthorized fetch") + } + w := controlRequest(h, "GET", path, "owner", "") + var got struct { + Issues []struct { + Number int + Title, Body, State, URL string + Labels []string + } + Available bool + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || !got.Available || len(got.Issues) != 2 || got.Issues[0].Number != 12 || got.Issues[0].Title != "Fix parser" || got.Issues[0].Body != "Public issue brief" || got.Issues[0].Labels[0] != "ready-for-agent" || got.Issues[1].State != "closed" { + t.Fatalf("issues %d %s", w.Code, w.Body.String()) + } + for _, secret := range []string{"private-upstream-token", "/private/", "secret@", "pull_request"} { + if strings.Contains(w.Body.String(), secret) { + t.Fatalf("leak %s", secret) + } + } + if w := controlRequest(h, "POST", path, "owner", ""); w.Code != 405 { + t.Fatal(w.Code) + } +} + +func TestControlPublicIssuesBoundedFailure(t *testing.T) { + _, x, h := controlFixture(t) + for _, tc := range []struct { + name, body string + status int + err error + }{ + {"rate limited", "private-token /private/path", 429, nil}, + {"oversized", strings.Repeat("x", (1<<20)+1), 200, nil}, + {"invalid json", "not JSON /private/path", 200, nil}, + {"timeout", "", 0, context.DeadlineExceeded}, + {"redirect", "", 302, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + mockIssues(t, func(*http.Request) (*http.Response, error) { + if tc.err != nil { + return nil, tc.err + } + return issueResponse(tc.status, tc.body), nil + }) + w := controlRequest(h, "GET", "/v1/control/projects/agent-forge/issues", "owner", "") + if w.Code != 502 || w.Body.Len() > 256 || strings.Contains(w.Body.String(), "private") { + t.Fatalf("failure %d %s", w.Code, w.Body.String()) + } + }) + } + x.config.Repositories[0].RepositoryURL = "https://secret@github.com/org/repo.git" + mockIssues(t, func(*http.Request) (*http.Response, error) { t.Fatal("invalid repository fetched"); return nil, nil }) + if w := controlRequest(h, "GET", "/v1/control/projects/agent-forge/issues", "owner", ""); w.Code != 422 { + t.Fatal(w.Code) + } + if w := controlRequest(h, "GET", "/v1/control/projects/missing/issues", "owner", ""); w.Code != 404 { + t.Fatal(w.Code) + } +} + +func TestControlTasksExcludeUnlinkedAndLinkExactRuns(t *testing.T) { + s, x, h := controlFixture(t) + repo := x.config.Repositories[0] + policy := x.config.resolvedPolicy(repo.WorkerPool, repo.Execution, repo.ID, repo.DefaultBranch) + source := "https://github.com/0k-lab/agent-forge/issues/12" + create := func(title, ref string) string { + t.Helper() + j, err := s.CreateCodingJobWithPolicyAndSource(protocol.CodingTask{RepositoryID: repo.ID, BaseSHA: strings.Repeat("a", 40), Instruction: title}, policy, ref) + if err != nil { + t.Fatal(err) + } + return j.ID + } + first := create("First run", source) + second := create("Second run", source) + create("Unlinked historical run", "") + create("Different exact source", source+"/") + create("Unsafe source", "https://secret@github.com/0k-lab/agent-forge/issues/12") + w := controlRequest(h, "GET", "/v1/control/overview", "owner", "") + var got struct { + Tasks []struct { + SourceRef string `json:"source_ref"` + Title, Lane string + RunCount int `json:"run_count"` + LatestRun struct{ ID string } `json:"latest_run"` + } + Jobs []any + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got.Tasks) != 1 || len(got.Jobs) != 5 { + t.Fatalf("tasks %d %s", w.Code, w.Body.String()) + } + found := false + for _, task := range got.Tasks { + if task.SourceRef == source { + found = true + if task.LatestRun.ID != second || task.RunCount != 2 || task.Lane != "Ready" { + t.Fatalf("link %#v", task) + } + } + if strings.Contains(task.Title, "historical") { + t.Fatal("historical job became task") + } + } + if !found { + t.Fatal("linked task absent") + } + mockIssues(t, func(*http.Request) (*http.Response, error) { + return issueResponse(200, `[{"number":12,"html_url":"`+source+`","title":"Product issue","state":"open","labels":[]}]`), nil + }) + w = controlRequest(h, "GET", "/v1/control/projects/agent-forge/issues", "owner", "") + json.Unmarshal(w.Body.Bytes(), &got) + if len(got.Tasks) != 1 || got.Tasks[0].Title != "Product issue" || got.Tasks[0].LatestRun.ID != second || got.Tasks[0].RunCount != 2 { + t.Fatalf("issue linkage %s", w.Body.String()) + } + path := "/v1/control/projects/agent-forge/runs?source_ref=" + source + w = controlRequest(h, "GET", path, "owner", "") + var runs struct { + Runs []struct { + ID string + Ordinal int + } + Total int + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &runs) != nil || runs.Total != 2 || len(runs.Runs) != 2 || runs.Runs[0].ID != first || runs.Runs[0].Ordinal != 1 || runs.Runs[1].ID != second || runs.Runs[1].Ordinal != 2 { + t.Fatalf("runs %d %s", w.Code, w.Body.String()) + } + if w := controlRequest(h, "GET", path, "", ""); w.Code != 401 { + t.Fatal(w.Code) + } +} + +func TestControlTaskLaneTruth(t *testing.T) { + for _, tc := range []struct { + state string + labels []string + run, want string + }{ + {"open", nil, "", "Backlog"}, {"open", []string{"ready-for-agent"}, "", "Ready"}, + {"open", nil, "pending", "Ready"}, {"open", nil, "retry_wait", "Ready"}, {"open", nil, "leased", "Working"}, + {"open", nil, "delivering", "Review & CI"}, {"open", nil, "succeeded", "Done"}, {"open", nil, "failed", "Blocked"}, + {"closed", nil, "", "Done"}, {"closed", nil, "failed", "Done"}, {"open", []string{"blocked", "ready-for-agent"}, "", "Blocked"}, + } { + issue := &controlIssue{State: tc.state, Labels: tc.labels} + var run *controlJob + if tc.run != "" { + run = &controlJob{Status: tc.run} + } + if got := controlTaskLane(issue, run); got != tc.want { + t.Fatalf("%#v: %s", tc, got) + } + } +} + +func TestControlSubmissionRequiresExactGitHubIssueSource(t *testing.T) { + s, _, h := controlFixture(t) + old := publicGitRunner + publicGitRunner = func(context.Context, string, string, time.Duration, int64, ...string) (string, error) { + return "", context.DeadlineExceeded + } + t.Cleanup(func() { publicGitRunner = old }) + for _, source := range []string{"https://example.com/task/1", "https://github.com/org/repo/pull/1", "https://github.com/org/repo/issues/01", "https://github.com/org/repo/issues/1/", "https://github.com/org/repo/issues/1#comment", "https://github.com/org/repo/issues/1?token=secret"} { + body, _ := json.Marshal(map[string]string{"project": "agent-forge", "title": "Fix", "instruction": "parser", "check_preset": "go", "source_ref": source}) + w := controlRequest(h, "POST", "/v1/control/jobs", "owner", string(body)) + if w.Code != 400 { + t.Fatalf("nonexact source %s: %d", source, w.Code) + } + } + page, err := s.RecentDebugJobs(context.Background(), 100, nil) + if err != nil || len(page.Items) != 0 { + t.Fatal("invalid source created a run") + } +} + +func TestControlIssuesBoundCountLabelsAndEncodedResponse(t *testing.T) { + _, _, h := controlFixture(t) + var raw []map[string]any + labels := []map[string]string{} + for i := 0; i < 25; i++ { + labels = append(labels, map[string]string{"name": "ordinary-" + strings.Repeat("a", i)}) + } + labels = append(labels, map[string]string{"name": "ready-for-agent"}) + for i := 1; i <= 60; i++ { + raw = append(raw, map[string]any{"number": i, "html_url": "https://github.com/0k-lab/agent-forge/issues/" + strconv.Itoa(i), "title": strings.Repeat("界", 200), "body": "brief", "state": "open", "labels": labels}) + } + body, _ := json.Marshal(raw) + mockIssues(t, func(*http.Request) (*http.Response, error) { return issueResponse(200, string(body)), nil }) + w := controlRequest(h, "GET", "/v1/control/projects/agent-forge/issues", "owner", "") + var got struct { + Issues []controlIssue + Tasks []controlTask + Truncated bool + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got.Issues) != 50 || !got.Truncated || len([]rune(got.Issues[0].Title)) != 120 || len(got.Issues[0].Labels) > 20 || got.Tasks[0].Lane != "Ready" { + t.Fatalf("bounds %d %s", w.Code, w.Body.String()[:min(500, w.Body.Len())]) + } + // JSON escaping must not turn a bounded upstream body into an unbounded browser response. + for _, issue := range raw[:50] { + issue["body"] = strings.Repeat("<", 8000) + } + body, _ = json.Marshal(raw[:50]) + // Use literal <, as GitHub may; encoding/json escapes it when producing our response. + body = []byte(strings.ReplaceAll(string(body), `\u003c`, "<")) + w = controlRequest(h, "GET", "/v1/control/projects/agent-forge/issues", "owner", "") + if w.Code != 502 || w.Body.Len() > 256 { + t.Fatalf("encoded response unbounded: %d %d", w.Code, w.Body.Len()) + } +} + +func TestControlLocalPRReferencesAreNotProductTasks(t *testing.T) { + s, _, h := controlFixture(t) + for _, ref := range []string{"https://github.com/org/repo/pull/12", "https://github.com/org/repo/issues/012", "https://github.com/org/repo/issues/12/"} { + if _, err := s.CreateJobWithSource("Historical linked job", ref); err != nil { + t.Fatal(err) + } + } + w := controlRequest(h, "GET", "/v1/control/overview", "owner", "") + var got struct { + Tasks []any + Jobs []any + } + if json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got.Tasks) != 0 || len(got.Jobs) != 3 { + t.Fatalf("nonissue became task %s", w.Body.String()) + } +} + +func TestControlIssueBriefTruncationIsExplicit(t *testing.T) { + _, _, h := controlFixture(t) + raw, _ := json.Marshal([]map[string]any{{"number": 1, "html_url": "https://github.com/0k-lab/agent-forge/issues/1", "title": "Large brief", "body": strings.Repeat("a", 9000), "state": "open"}}) + mockIssues(t, func(*http.Request) (*http.Response, error) { return issueResponse(200, string(raw)), nil }) + w := controlRequest(h, "GET", "/v1/control/projects/agent-forge/issues", "owner", "") + var got struct { + Issues []struct { + Body string + Truncated bool `json:"body_truncated"` + } + } + if json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got.Issues) != 1 || len(got.Issues[0].Body) != 8000 || !got.Issues[0].Truncated { + t.Fatal("brief was silently truncated") + } +} + +func TestControlLinkedLegacyRunHistoryRemainsReadable(t *testing.T) { + s, _, h := controlFixture(t) + source := "https://github.com/org/repo/issues/12" + job, err := s.CreateJobWithSource("Linked legacy run", source) + if err != nil { + t.Fatal(err) + } + w := controlRequest(h, "GET", "/v1/control/projects/_unassigned/runs?source_ref="+source, "owner", "") + var got struct { + Runs []controlJob + Total int + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || got.Total != 1 || got.Runs[0].ID != job.ID { + t.Fatalf("legacy history %d %s", w.Code, w.Body.String()) + } +} + +func TestControlRunHistoryBoundsAndLatestCreationWins(t *testing.T) { + s, x, h := controlFixture(t) + repo := x.config.Repositories[0] + source := "https://github.com/0k-lab/agent-forge/issues/12" + policy := x.config.resolvedPolicy(repo.WorkerPool, repo.Execution, repo.ID, repo.DefaultBranch) + var latest string + for range 24 { + job, err := s.CreateCodingJobWithPolicyAndSource(protocol.CodingTask{RepositoryID: repo.ID, BaseSHA: strings.Repeat("a", 40), Instruction: "Linked task"}, policy, source) + if err != nil { + t.Fatal(err) + } + latest = job.ID + } + at := time.Now().UTC() + generation := strings.Repeat("b", 32) + if err := s.ClaimWorkerSlot("worker-1", 0, "worker-1", repo.WorkerPool, generation, at); err != nil { + t.Fatal(err) + } + lease, ok, err := s.LeaseNextForPool("worker-1", repo.WorkerPool, generation, at) + if err != nil || !ok { + t.Fatal(err) + } + if _, err := s.FailLeaseAt(lease.JobID, lease.AttemptID, "worker-1", generation, protocol.FailureInvalidTask, store.TerminalFailure, at.Add(time.Second)); err != nil { + t.Fatal(err) + } + w := controlRequest(h, "GET", "/v1/control/overview", "owner", "") + var overview struct{ Tasks []controlTask } + if json.Unmarshal(w.Body.Bytes(), &overview) != nil || len(overview.Tasks) != 1 || overview.Tasks[0].LatestRun.ID != latest || overview.Tasks[0].Lane != "Ready" { + t.Fatalf("older update replaced latest run: %s", w.Body.String()) + } + w = controlRequest(h, "GET", "/v1/control/projects/agent-forge/runs?source_ref="+source, "owner", "") + var group struct { + Runs []controlJob + Total int + Truncated bool + } + if json.Unmarshal(w.Body.Bytes(), &group) != nil || len(group.Runs) != 20 || group.Total != 24 || !group.Truncated || group.Runs[0].Ordinal != 5 || group.Runs[19].Ordinal != 24 || group.Runs[19].ID != latest { + t.Fatalf("history bounds: %s", w.Body.String()) + } +} + +func TestControlIssueClosedMetadata(t *testing.T) { + _, _, h := controlFixture(t) + mockIssues(t, func(*http.Request) (*http.Response, error) { + return issueResponse(200, `[ {"number":53,"html_url":"https://github.com/0k-lab/agent-forge/issues/53","title":"Completed","state":"closed","closed_at":"2026-08-29T20:44:18Z","closed_by":{"login":"kricha-lab-dev-worker[bot]","token":"secret-token"}} ]`), nil + }) + w := controlRequest(h, "GET", "/v1/control/projects/agent-forge/issues", "owner", "") + if w.Code != 200 || !strings.Contains(w.Body.String(), `"closed_at":"2026-08-29T20:44:18Z"`) || !strings.Contains(w.Body.String(), `"closed_by":"kricha-lab-dev-worker[bot]"`) || strings.Contains(w.Body.String(), "secret-token") { + t.Fatalf("metadata: %d %s", w.Code, w.Body.String()) + } +} + +func TestControlIssueActivityCanonicalBoundedReadOnly(t *testing.T) { + _, _, h := controlFixture(t) + calls := 0 + mockIssues(t, func(r *http.Request) (*http.Response, error) { + calls++ + deadline, ok := r.Context().Deadline() + if r.Method != "GET" || r.URL.Scheme != "https" || r.URL.Host != "api.github.com" || r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" || !ok || time.Until(deadline) > 5*time.Second { + t.Fatalf("unsafe request %v", r) + } + if r.URL.Path == "/repos/0k-lab/agent-forge/issues/53" { + return issueResponse(200, `{"number":53,"html_url":"https://github.com/0k-lab/agent-forge/issues/53","state":"closed","closed_at":"2026-08-29T20:44:18Z","closed_by":{"login":"kricha-lab-dev-worker[bot]"}}`), nil + } + if r.URL.Path == "/repos/0k-lab/agent-forge/pulls/55" { + return issueResponse(502, ""), nil + } + if r.URL.Path != "/repos/0k-lab/agent-forge/issues/53/timeline" || r.URL.Query().Get("per_page") != "50" { + t.Fatalf("unexpected URL %s", r.URL) + } + good := `{"event":"cross-referenced","source":{"type":"issue","issue":{"number":55,"html_url":"https://github.com/0k-lab/agent-forge/pull/55","pull_request":{"html_url":"https://github.com/0k-lab/agent-forge/pull/55","merged_at":"2026-08-29T20:44:17Z"}}}}` + records := []string{good, good, strings.ReplaceAll(good, "0k-lab/agent-forge", "other/repo"), strings.ReplaceAll(good, "https://github.com", "https://secret@github.com"), strings.ReplaceAll(good, "2026-08-29T20:44:17Z", "bad"), strings.ReplaceAll(good, "cross-referenced", "commented"), strings.ReplaceAll(good, `"number":55`, `"number":56`)} + records = append(records, strings.ReplaceAll(good, `"number":55`, `"number":"bad"`), strings.ReplaceAll(good, `"merged_at":"2026-08-29T20:44:17Z"`, `"merged_at":null`)) + for len(records) < 50 { + records = append(records, `{}`) + } + records = append(records, strings.ReplaceAll(good, "55", "99")) + return issueResponse(200, "["+strings.Join(records, ",")+"]"), nil + }) + path := "/v1/control/projects/agent-forge/issues/53/activity" + if w := controlRequest(h, "GET", path, "", ""); w.Code != 401 { + t.Fatal(w.Code) + } + if w := controlRequest(h, "POST", path, "owner", ""); w.Code != 405 { + t.Fatal(w.Code) + } + if calls != 0 { + t.Fatal("unauthorized upstream call") + } + w := controlRequest(h, "GET", path, "owner", "") + var got struct { + Issue struct{ State, ClosedAt, ClosedBy string } + MergedPRs []struct { + Number int + URL string + } `json:"merged_prs"` + Truncated bool + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got.MergedPRs) != 1 || got.MergedPRs[0].Number != 55 || !got.Truncated || !strings.Contains(w.Body.String(), "kricha-lab-dev-worker[bot]") || calls != 3 { + t.Fatalf("activity %d %s calls=%d", w.Code, w.Body.String(), calls) + } + for _, bad := range []string{"secret@", "other/repo", "/pull/99"} { + if strings.Contains(w.Body.String(), bad) { + t.Fatal("unsafe evidence", bad) + } + } +} + +func TestControlIssueActivityFailures(t *testing.T) { + _, x, h := controlFixture(t) + path := "/v1/control/projects/agent-forge/issues/53/activity" + for _, timeline := range []bool{false, true} { + for _, mode := range []string{"redirect", "oversize", "timeout", "malformed"} { + t.Run(strconv.FormatBool(timeline)+"/"+mode, func(t *testing.T) { + calls := 0 + mockIssues(t, func(r *http.Request) (*http.Response, error) { + calls++ + if timeline && calls == 1 { + return issueResponse(200, `{"number":53,"html_url":"https://github.com/0k-lab/agent-forge/issues/53","state":"closed"}`), nil + } + limit := 1 + if timeline { + limit = 2 + } + if calls > limit { + t.Fatal("followed redirect") + } + switch mode { + case "redirect": + response := issueResponse(302, "") + response.Header.Set("Location", "https://evil.example/private") + return response, nil + case "oversize": + return issueResponse(200, strings.Repeat("x", (1<<20)+1)), nil + case "timeout": + return nil, context.DeadlineExceeded + default: + return issueResponse(200, `{"private":"/private/token"}`), nil + } + }) + w := controlRequest(h, "GET", path, "owner", "") + if w.Code != 502 || w.Body.Len() > 256 || strings.Contains(w.Body.String(), "private") { + t.Fatalf("failure %d %s", w.Code, w.Body.String()) + } + }) + } + } + mockIssues(t, func(*http.Request) (*http.Response, error) { t.Fatal("invalid input fetched"); return nil, nil }) + for _, number := range []string{"0", "053", "-1", "9007199254740992", "abc"} { + if w := controlRequest(h, "GET", strings.Replace(path, "53", number, 1), "owner", ""); w.Code != 400 { + t.Fatal(number, w.Code) + } + } + x.config.Repositories[0].RepositoryURL = "https://secret@github.com/org/repo.git" + if w := controlRequest(h, "GET", path, "owner", ""); w.Code != 422 { + t.Fatal(w.Code) + } +} + +func TestControlSubmissionSourceMatchesSelectedProject(t *testing.T) { + s, x, h := controlFixture(t) + other := x.config.Repositories[0] + other.ID = "other" + other.RepositoryURL = "https://github.com/other/repo.git" + x.config.Repositories = append(x.config.Repositories, other) + calls := 0 + old := publicGitRunner + publicGitRunner = func(context.Context, string, string, time.Duration, int64, ...string) (string, error) { + calls++ + return "", context.DeadlineExceeded + } + t.Cleanup(func() { publicGitRunner = old }) + for _, source := range []string{ + "https://github.com/other/repo/issues/12", "https://github.com/0k-lab/agent-forge-extra/issues/12", + "https://github.com/0K-lab/agent-forge/issues/12", "https://github.com/0k-lab/Agent-Forge/issues/12", + "https://GitHub.com/0k-lab/agent-forge/issues/12", "https://github.com.evil/0k-lab/agent-forge/issues/12", + "https://github.com/0k-lab%2Fagent-forge/issues/12", "https://github.com/0k-lab/agent-forge/issues/12/extra", + } { + body, _ := json.Marshal(map[string]string{"project": "agent-forge", "title": "Fix", "instruction": "parser", "check_preset": "go", "source_ref": source}) + w := controlRequest(h, "POST", "/v1/control/jobs", "owner", string(body)) + if w.Code != 400 || w.Body.Len() > 256 || strings.Contains(w.Body.String(), source) { + t.Errorf("wrong source %s: %d %s", source, w.Code, w.Body.String()) + } + } + if calls != 0 { + t.Fatalf("wrong-project requests prepared source: %d", calls) + } + page, err := s.RecentDebugJobs(context.Background(), 100, nil) + if err != nil || len(page.Items) != 0 { + t.Fatal("invalid source mutated jobs") + } +} + +func TestControlTaskLimitCountsOnlyConfiguredIssueTasks(t *testing.T) { + for _, validCount := range []int{2, 100, 101} { + t.Run(strconv.Itoa(validCount), func(t *testing.T) { + s, x, _ := controlFixture(t) + repo := x.config.Repositories[0] + create := func(project, source string) { + t.Helper() + _, err := s.CreateCodingJobWithPolicyAndSource(protocol.CodingTask{RepositoryID: project, BaseSHA: strings.Repeat("a", 40), Instruction: "Task"}, x.config.resolvedPolicy(repo.WorkerPool, repo.Execution, project, repo.DefaultBranch), source) + if err != nil { + t.Fatal(err) + } + } + for i := 1; i <= validCount; i++ { + create(repo.ID, "https://github.com/0k-lab/agent-forge/issues/"+strconv.Itoa(i)) + } + for i := 1; i <= 110; i++ { + n := strconv.Itoa(i) + for _, source := range []string{"https://example.com/task/" + n, "https://github.com/other/repo/issues/" + n, "https://github.com/0k-lab/agent-forge/pull/" + n, "https://github.com/0k-lab/agent-forge/issues/0" + n} { + create(repo.ID, source) + } + create("removed-project", "https://github.com/0k-lab/agent-forge/issues/"+n) + } + tasks, truncated, err := x.localControlTasks(context.Background()) + if err != nil || len(tasks) != min(100, validCount) || truncated != (validCount > 100) { + t.Fatalf("valid=%d got=%d truncated=%v err=%v", validCount, len(tasks), truncated, err) + } + for _, task := range tasks { + if task.Project != repo.ID || !strings.HasPrefix(task.SourceRef, "https://github.com/0k-lab/agent-forge/issues/") { + t.Fatalf("invalid task %#v", task) + } + } + }) + } +} + +func TestControlTasksWithoutConfiguration(t *testing.T) { + s, x, _ := controlFixture(t) + if _, err := s.CreateJobWithSource("Legacy", "https://github.com/org/repo/issues/1"); err != nil { + t.Fatal(err) + } + x.config = nil + tasks, truncated, err := x.localControlTasks(context.Background()) + if err != nil || truncated || len(tasks) != 0 { + t.Fatalf("tasks=%v truncated=%v err=%v", tasks, truncated, err) + } +} diff --git a/internal/gate/control_test.go b/internal/gate/control_test.go new file mode 100644 index 0000000..45a57c2 --- /dev/null +++ b/internal/gate/control_test.go @@ -0,0 +1,343 @@ +package gate + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "agent-forge/internal/protocol" + "agent-forge/internal/store" +) + +func controlFixture(t *testing.T) (*store.Store, *server, http.Handler) { + t.Helper() + s, err := store.Open(filepath.Join(secureTempDir(t), "control.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + config := publicGateConfig(t) + x := newServer(s, nil, "owner", DefaultOptions()) + x.config = &config + return s, x, x.routes() +} + +func controlRequest(h http.Handler, method, path, token, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func TestControlAssetsAndAuthentication(t *testing.T) { + _, _, h := controlFixture(t) + for path, mime := range map[string]string{"/app": "text/html", "/app/": "text/html", "/app/app.css": "text/css", "/app/app.js": "text/javascript"} { + w := controlRequest(h, "GET", path, "", "") + if w.Code != 200 || !strings.HasPrefix(w.Header().Get("Content-Type"), mime) || w.Body.Len() == 0 { + t.Fatalf("%s: %d %s", path, w.Code, w.Body.String()) + } + if w.Header().Get("Permissions-Policy") != "camera=(), microphone=(), geolocation=()" { + t.Fatalf("%s missing permissions policy", path) + } + for _, method := range []string{"POST", "HEAD", "PUT", "DELETE"} { + if w := controlRequest(h, method, path, "", ""); w.Code != 405 { + t.Fatalf("%s %s: %d", method, path, w.Code) + } + } + } + if w := controlRequest(h, "GET", "/app/missing", "", ""); w.Code != 404 { + t.Fatal(w.Code) + } + for path, method := range map[string]string{"/v1/control/overview": "GET", "/v1/control/jobs/" + strings.Repeat("a", 32): "GET", "/v1/control/jobs": "POST"} { + for _, token := range []string{"", "worker", "wrong"} { + if w := controlRequest(h, method, path, token, "{}"); w.Code != 401 { + t.Fatalf("%s: %d", path, w.Code) + } + } + } + if w := controlRequest(h, "GET", "/debug/", "", ""); w.Code != 200 { + t.Fatal("debug unavailable") + } +} + +func TestControlOverviewProjectsSlotsAndSafeCards(t *testing.T) { + s, x, h := controlFixture(t) + repo := x.config.Repositories[0] + policy := x.config.resolvedPolicy(repo.WorkerPool, repo.Execution, repo.ID, repo.DefaultBranch) + task := protocol.CodingTask{RepositoryID: repo.ID, BaseSHA: strings.Repeat("a", 40), Instruction: "\n Fix the parser \nDetails", Tests: [][]string{{"go", "test", "./internal/parser"}}} + job, err := s.CreateCodingJobWithPolicyAndSource(task, policy, "https://github.com/org/repo/issues/12") + if err != nil { + t.Fatal(err) + } + at := time.Now().UTC() + if err := s.ClaimWorkerSlot("worker-1", 0, "worker-1", repo.WorkerPool, strings.Repeat("b", 32), at); err != nil { + t.Fatal(err) + } + lease, ok, err := s.LeaseNextForPool("worker-1", repo.WorkerPool, strings.Repeat("b", 32), at) + if err != nil || !ok || lease.JobID != job.ID { + t.Fatalf("lease %v %v", ok, err) + } + // The agent belongs to the persisted run, even after config changes. + x.config.Repositories[0].Execution.PluginID = "replacement" + w := controlRequest(h, "GET", "/v1/control/overview", "owner", "") + var got struct { + Jobs []struct{ ID, Title, Project, Status, SourceRef, WorkerID, Agent string } + } + // Decode snake_case through a generic map as a wire-contract assertion. + var wire map[string]json.RawMessage + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &wire) != nil { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + if json.Unmarshal(wire["jobs"], &got.Jobs) != nil || len(got.Jobs) != 1 || got.Jobs[0].Title != "Fix the parser" || got.Jobs[0].Project != repo.ID || got.Jobs[0].Status != "leased" || got.Jobs[0].Agent != "codex" { + t.Fatalf("jobs: %s", w.Body.String()) + } + var projects, workers []map[string]any + json.Unmarshal(wire["projects"], &projects) + json.Unmarshal(wire["workers"], &workers) + if len(projects) != 1 || projects[0]["default_branch"] != "main" || projects[0]["worker_pool"] != repo.WorkerPool || projects[0]["public_source"] != true || projects[0]["delivery"] != false { + t.Fatalf("projects %s", wire["projects"]) + } + if len(workers) != 2 || workers[0]["base_id"] != "worker-1" || workers[0]["slot"] != float64(0) || workers[0]["connected"] != true || workers[0]["occupied"] != true || workers[0]["active_job_id"] != job.ID || workers[0]["agent"] != "codex" || workers[1]["connected"] != false || workers[1]["occupied"] != false || workers[1]["last_seen"] != nil { + t.Fatalf("workers %s", wire["workers"]) + } + for _, secret := range []string{x.config.PublicRepositoryRoot, x.config.Database, strings.Repeat("b", 32), "FORGE_WORKER_TOKEN", "repository_url", "environment"} { + if secret != "" && strings.Contains(w.Body.String(), secret) { + t.Fatalf("leaked %q", secret) + } + } +} + +func TestControlTitleAndSourceSafety(t *testing.T) { + s, _, h := controlFixture(t) + for _, source := range []string{"javascript:alert(1)", "https://user:secret@example.com/issue", "/private/repo", "https://example.com/issue?token=secret", "https://example.com/#secret"} { + _, err := s.CreateJobWithSource("\n"+strings.Repeat("界", 200)+"\nsecond", source) + if err != nil { + t.Fatal(err) + } + } + w := controlRequest(h, "GET", "/v1/control/overview", "owner", "") + var got struct { + Jobs []struct { + Title string `json:"title"` + Source string `json:"source_ref"` + } + } + if json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got.Jobs) != 5 { + t.Fatalf("%s", w.Body.String()) + } + for _, j := range got.Jobs { + if len([]rune(j.Title)) != 120 || j.Source != "" { + t.Fatalf("unsafe card %#v", j) + } + } +} + +func TestControlDetailActiveEvidenceAndDelivery(t *testing.T) { + s, x, h := controlFixture(t) + repo := x.config.Repositories[0] + base, candidate := strings.Repeat("a", 40), strings.Repeat("c", 40) + job, err := s.CreateCodingJobWithPolicy(protocol.CodingTask{RepositoryID: repo.ID, Repository: "/private/repository", BaseSHA: base, Instruction: "Fix parser\nKeep Unicode intact", Tests: [][]string{{"check", "/private/check", "--token=secret"}}}, x.config.resolvedPolicy(repo.WorkerPool, repo.Execution, repo.ID, repo.DefaultBranch)) + if err != nil { + t.Fatal(err) + } + at := time.Now().UTC() + generation := strings.Repeat("b", 32) + if err := s.ClaimWorkerSlot("worker-1", 0, "worker-1", repo.WorkerPool, generation, at); err != nil { + t.Fatal(err) + } + lease, ok, err := s.LeaseNextForPool("worker-1", repo.WorkerPool, generation, at) + if err != nil || !ok { + t.Fatal(err) + } + index, exit := 0, 0 + if err := s.BindEvidenceLeaseAt(job.ID, lease.AttemptID, "worker-1", generation, []protocol.AttemptEvidence{{EvidenceID: strings.Repeat("d", 32), Phase: protocol.EvidencePhaseScopedCheck, Reason: protocol.EvidenceReasonScopedCheckPassed, CheckIndex: &index, ExitCode: &exit, DurationMS: 15, BaseSHA: base, CandidateSHA: candidate, Output: protocol.EvidenceRedactedMarker, OutputRedacted: true}}, at.Add(time.Millisecond)); err != nil { + t.Fatal(err) + } + path := "/v1/control/jobs/" + job.ID + w := controlRequest(h, "GET", path, "owner", "") + var detail struct { + Job struct{ Status string } `json:"job"` + Instruction string `json:"instruction"` + Attempts []struct { + Ordinal int + WorkerID string `json:"worker_id"` + Evidence []safeEvidence + } `json:"attempts"` + Timeline []store.DebugEvent `json:"timeline"` + Delivery *safeDelivery `json:"delivery"` + Diagnostics struct { + BaseSHA string `json:"base_sha"` + CandidateSHA string `json:"candidate_sha"` + } `json:"diagnostics"` + SubagentTelemetry string `json:"subagent_telemetry"` + CheckCount int `json:"check_count"` + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &detail) != nil || detail.Job.Status != "leased" || detail.Instruction != job.Task.Instruction || len(detail.Attempts) != 1 || detail.Attempts[0].WorkerID != "worker-1" || len(detail.Attempts[0].Evidence) != 1 || detail.Attempts[0].Evidence[0].DurationMS != 15 || len(detail.Timeline) != 2 || detail.CheckCount != 1 || detail.SubagentTelemetry != "No subagent telemetry reported for this run" { + t.Fatalf("detail %d %s", w.Code, w.Body.String()) + } + for _, secret := range []string{"/private/", "--token=secret", "private-command-output", "repository_url"} { + if strings.Contains(w.Body.String(), secret) { + t.Fatalf("leaked %s", secret) + } + } + delivery := store.Delivery{JobID: job.ID, AttemptID: lease.AttemptID, CandidateSHA: candidate, ExpectedTreeSHA: strings.Repeat("e", 40), ParentSHA: base, CandidateRef: "refs/agent-forge/candidates/" + job.ID + "/" + lease.AttemptID, RepositoryID: repo.ID, RepositoryURL: repo.RepositoryURL, DefaultBranch: repo.DefaultBranch, Branch: "forge/" + job.ID, PRTitle: "Fix parser", PRBody: "private-delivery-body", MaxAttempts: 3} + if _, err := s.CompleteCandidateDeliveryLeaseAt(job.ID, lease.AttemptID, "worker-1", generation, delivery, at.Add(time.Second)); err != nil { + t.Fatal(err) + } + if _, ok, err := s.ClaimDelivery(at.Add(2 * time.Second)); err != nil || !ok { + t.Fatal(err) + } + if err := s.UpdateDelivery(job.ID, "ci", "https://github.com/org/repo/pull/1", 1, "pending", at.Add(3*time.Second)); err != nil { + t.Fatal(err) + } + w = controlRequest(h, "GET", path, "owner", "") + json.Unmarshal(w.Body.Bytes(), &detail) + if w.Code != 200 || detail.Job.Status != "delivering" || detail.Delivery == nil || detail.Delivery.Phase != "ci" || detail.Delivery.CIState != "pending" || detail.Delivery.PRURL != "https://github.com/org/repo/pull/1" || detail.Diagnostics.CandidateSHA != candidate || strings.Contains(w.Body.String(), "private-delivery-body") { + t.Fatalf("delivery %s", w.Body.String()) + } + if err := s.UpdateDelivery(job.ID, "merging", "", 0, "success", at.Add(4*time.Second)); err != nil { + t.Fatal(err) + } + if err := s.CompleteDelivery(job.ID, strings.Repeat("f", 40), at.Add(5*time.Second)); err != nil { + t.Fatal(err) + } + w = controlRequest(h, "GET", path, "owner", "") + json.Unmarshal(w.Body.Bytes(), &detail) + if detail.Job.Status != "succeeded" || detail.Delivery.Phase != "merged" || detail.Delivery.MergeSHA != strings.Repeat("f", 40) { + t.Fatalf("merge %s", w.Body.String()) + } + if w := controlRequest(h, "GET", "/v1/control/jobs/"+strings.Repeat("0", 32), "owner", ""); w.Code != 404 { + t.Fatal(w.Code) + } +} + +func TestControlSubmitPinsCurrentDefaultHead(t *testing.T) { + s, x, h := controlFixture(t) + fixture, first, head := gitHTTPFixture(t) + original := publicCloneURL + publicCloneURL = func(publicSource) string { return fixture } + t.Cleanup(func() { publicCloneURL = original }) + // Seed the existing preparation boundary at an older acceptable base. + if _, err := provisionPublicRepository(context.Background(), *x.config, x.config.Repositories[0], first); err != nil { + t.Fatal(err) + } + w := controlRequest(h, "POST", "/v1/control/jobs", "owner", `{"project":"agent-forge","title":"Fix parser","instruction":"Preserve Unicode","source_ref":"https://github.com/0k-lab/agent-forge/issues/12","check_preset":"go"}`) + var response struct { + ID string `json:"id"` + } + if w.Code != 201 || json.Unmarshal(w.Body.Bytes(), &response) != nil { + t.Fatalf("submit %d %s", w.Code, w.Body.String()) + } + job, err := s.Job(response.ID) + if err != nil || job.Status != "pending" || job.Task == nil || job.Task.BaseSHA != head || job.Task.BaseSHA == first || job.Task.Instruction != "Fix parser\n\nPreserve Unicode" || job.Task.RepositoryID != "agent-forge" || !strings.HasPrefix(job.Task.Repository, x.config.PublicRepositoryRoot+"/") || job.PolicyVersion != 1 || job.WorkerPool != "general" || job.SourceRef != "https://github.com/0k-lab/agent-forge/issues/12" || len(job.Task.Tests) != 1 || strings.Join(job.Task.Tests[0], " ") != "go test ./..." { + t.Fatalf("pinned job %#v %v", job, err) + } + if strings.Contains(w.Body.String(), x.config.PublicRepositoryRoot) { + t.Fatal("response leaked path") + } + generation := strings.Repeat("b", 32) + at := time.Now().UTC() + if err := s.ClaimWorkerSlot("worker-1", 0, "worker-1", "general", generation, at); err != nil { + t.Fatal(err) + } + lease, ok, err := s.LeaseNextForPool("worker-1", "general", generation, at) + if err != nil || !ok || lease.JobID != job.ID || lease.Task.BaseSHA != head || lease.Policy.Execution.PluginID != "codex" { + t.Fatalf("normal lease %#v %v", lease, err) + } +} + +func TestControlSubmitRejectsWithoutCreatingJob(t *testing.T) { + s, x, h := controlFixture(t) + original := publicGitRunner + calls := 0 + publicGitRunner = func(context.Context, string, string, time.Duration, int64, ...string) (string, error) { + calls++ + return "", context.DeadlineExceeded + } + t.Cleanup(func() { publicGitRunner = original }) + for _, body := range []string{ + `{"project":"missing","title":"Fix","instruction":"parser","check_preset":"go"}`, + `{"project":"agent-forge","title":"","instruction":"parser","check_preset":"go"}`, + `{"project":"agent-forge","title":"Fix","instruction":"parser","base_sha":"abc"}`, + `{"project":"agent-forge","title":"Fix","instruction":"parser","source_ref":"https://secret@example.com/issue","check_preset":"go"}`, + `{"project":"agent-forge","title":"Fix","instruction":"parser","check_preset":"unknown"}`, + `{"project":"agent-forge","title":"Fix","instruction":"parser","check_preset":"go","checks":"go test ./..."}`, + } { + if w := controlRequest(h, "POST", "/v1/control/jobs", "owner", body); w.Code != 400 { + t.Fatalf("invalid submit %d %s", w.Code, w.Body.String()) + } + } + if calls != 0 { + t.Fatal("invalid requests reached Git") + } + body := `{"project":"agent-forge","title":"Fix","instruction":"parser","checks":"go test ./internal/parser\ngo vet ./internal/parser"}` + w := controlRequest(h, "POST", "/v1/control/jobs", "owner", body) + if w.Code != 502 || strings.Contains(w.Body.String(), x.config.PublicRepositoryRoot) || w.Body.Len() > 512 { + t.Fatalf("unavailable %d %s", w.Code, w.Body.String()) + } + x.config.Repositories[0].RepositoryURL = "" + if w := controlRequest(h, "POST", "/v1/control/jobs", "owner", body); w.Code != 422 { + t.Fatalf("unconfigured %d %s", w.Code, w.Body.String()) + } + page, err := s.RecentDebugJobs(context.Background(), 100, nil) + if err != nil || len(page.Items) != 0 { + t.Fatalf("created jobs %#v %v", page, err) + } +} + +func TestControlUIContract(t *testing.T) { + _, _, h := controlFixture(t) + js := controlRequest(h, "GET", "/app/app.js", "", "").Body.String() + for _, mapping := range []string{`pending: 'Ready'`, `retry_wait: 'Ready'`, `leased: 'Working'`, `delivering: 'Review & CI'`, `succeeded: 'Done'`, `failed: 'Blocked'`} { + if !strings.Contains(js, mapping) { + t.Fatalf("missing authoritative mapping %s", mapping) + } + } + for _, unsafe := range []string{"innerHTML", "outerHTML", "insertAdjacentHTML", "localStorage", "sessionStorage", "document.cookie", "/v1/debug/"} { + if strings.Contains(js, unsafe) { + t.Fatalf("unsafe UI %s", unsafe) + } + } + for _, needed := range []string{"textContent", "Authorization", "setInterval", "/v1/control/overview", "/v1/control/jobs", "No subagent telemetry reported for this run", "showModal", "submitPending"} { + if !strings.Contains(js, needed) { + t.Fatalf("missing UI behavior %s", needed) + } + } + html := controlRequest(h, "GET", "/app/", "", "").Body.String() + for _, needed := range []string{`type="password"`, `id="task-form"`, `id="project-filter"`, `id="workers-view"`, `aria-live="polite"`, ` c.textContent).join(''); } + appendChild(node) { this.children.push(node); return node; } + replaceChildren() { this.text = ''; this.children = []; } + setAttribute(name, value) { this.attributes[name] = value; } + removeAttribute(name) { delete this.attributes[name]; } + addEventListener(name, action) { this.listeners[name] = action; } + focus() { this.focused = true; activeElement = this; } + querySelectorAll(tag) { return this.children.flatMap(node => [...(node.tagName === tag ? [node] : []), ...node.querySelectorAll(tag)]); } + querySelector(tag) { for (const node of this.children) { if (node.tagName === tag) return node; const found = node.querySelector(tag); if (found) return found; } return null; } + showModal() { this.open = true; } + close() { this.open = false; this.listeners.close?.(); } + reset() { nodes['task-preset'].value = 'go'; } + } + const html = readFileSync(path.join(__dirname, 'app/index.html'), 'utf8'); + const nodes = Object.fromEntries([...html.matchAll(/id="([^"]+)"/g)].map(m => [m[1], new Element()])); + const calls = []; + const context = vm.createContext({ + document: {get activeElement() { return activeElement; }, getElementById: id => nodes[id], createElement: tag => new Element(tag), addEventListener() {}, hidden: false}, + window: {addEventListener() {}}, URL, Date, console, + setInterval: (fn, ms) => { assert.equal(ms, 5000); }, + fetch: async (url, options) => { calls.push({url, options}); return {ok: true, status: 200, json: async () => ({projects: [], workers: [], jobs: []})}; } + }); + vm.runInContext(readFileSync(path.join(__dirname, 'app/app.js'), 'utf8'), context); + return {nodes, context, calls, run: code => vm.runInContext(code, context)}; +} +const project = {id: 'parser', default_branch: 'main', worker_pool: 'coding', agent: 'codex', public_source: true, delivery: true}; + +test('authoritative board states, text-only titles, filtering and truthful slot capacity', () => { + const w = workspace(); + const statuses = ['pending', 'retry_wait', 'leased', 'delivering', 'succeeded', 'failed']; + const jobs = statuses.map((status, i) => ({id: String(i), title: ` ${status}`, source_ref: `https://github.com/org/repo/issues/${i + 1}`, status, project: 'parser', created_at: '2026-09-01T00:00:00Z', updated_at: '2026-09-01T00:01:00Z'})); + w.context.data = {projects: [project], jobs, tasks: jobs.map(j => ({source_ref:j.source_ref,project:j.project,title:j.title,latest_run:j,run_count:1})), workers: [ + {id: 'worker', base_id: 'worker', slot: 0, pool: 'coding', connected: true, occupied: true, active_job_id: '2', agent: 'codex'}, + {id: 'worker#1', base_id: 'worker', slot: 1, pool: 'coding', connected: true, occupied: false}, + {id: 'worker#2', base_id: 'worker', slot: 2, pool: 'coding', connected: false, occupied: false} + ]}; + w.run('renderOverview(data)'); + assert.equal(w.nodes.board.children.length, 6); + assert.match(w.nodes.board.children[1].textContent, /pending.*retry_wait/); + for (const [column, status] of [[2,'leased'],[3,'delivering'],[4,'succeeded'],[5,'failed']]) assert.match(w.nodes.board.children[column].textContent, new RegExp(status)); + assert.match(w.nodes.board.textContent, //); + assert.match(w.nodes['worker-summary'].textContent, /3 slots · 2 connected · 1 occupied · 1 connected and free/); + w.nodes['project-filter'].value = 'different'; w.run('renderBoard()'); + assert.doesNotMatch(w.nodes.board.textContent, / { + const w = workspace(); w.context.data = {projects: [project], jobs: [], workers: []}; + w.run('renderOverview(data); openTask()'); + w.nodes['task-preset'].value = ''; + w.nodes['task-preset'].listeners.change(); + assert.equal(w.nodes['task-checks'].required, true); + w.run('openTask()'); + assert.equal(w.nodes['scoped-checks'].hidden, true); + assert.equal(w.nodes['task-checks'].required, false); + assert.equal(w.nodes['task-title'].focused, true); +}); + +test('submission uses memory auth, no SHA, and allows only one pending request', async () => { + const w = workspace(); let resolve; + w.context.fetch = (url, options) => {w.calls.push({url, options}); return new Promise(done => {resolve = done;});}; + w.run(`token = 'owner-token'`); + for (const [id, value] of Object.entries({'task-project':'parser','task-title':'Fix parser','task-instruction':'Handle Unicode','task-source':'','task-preset':'go'})) w.nodes[id].value = value; + const first = w.run('submitTask({preventDefault(){}})'); + await w.run('submitTask({preventDefault(){}})'); + assert.equal(w.calls.length, 1); + assert.equal(w.nodes['submit-task'].disabled, true); + assert.equal(w.calls[0].options.headers.Authorization, 'Bearer owner-token'); + assert.equal(w.calls[0].options.credentials, 'omit'); + assert.equal(JSON.parse(w.calls[0].options.body).check_preset, 'go'); + assert.equal('base_sha' in JSON.parse(w.calls[0].options.body), false); + resolve({ok:false,status:422}); await first; + assert.equal(w.nodes['submit-task'].disabled, false); + assert.match(w.nodes['task-error'].textContent, /unavailable/); +}); + +test('unauthorized locks and clears data; a late overview cannot repopulate a locked workspace', async () => { + const w = workspace(); + w.context.fetch = async () => ({ok:false,status:401}); + w.run(`token = 'wrong'`); + await w.run('refresh()'); + assert.equal(w.run('token'), ''); + assert.match(w.nodes.notice.textContent, /Unauthorized/); + assert.equal(w.nodes['new-task'].disabled, true); + let resolve; + w.context.fetch = () => new Promise(done => {resolve = done;}); + w.run(`token = 'owner'`); + const pending = w.run('refresh()'); + w.run('lockWorkspace()'); + resolve({ok:true,status:200,json:async()=>({projects:[project],workers:[],jobs:[]})}); + await pending; + assert.equal(w.run('overview'), null); + assert.equal(w.nodes['auth-panel'].hidden, false); +}); + +test('auto-refresh preserves a focused task card and expanded diagnostics', () => { + const w = workspace(); + const job = {id: 'task', source_ref: 'https://github.com/org/repo/issues/1', title: 'Fix parser', project: 'parser', status: 'pending', created_at: '2026-09-01T00:00:00Z', updated_at: '2026-09-01T00:01:00Z'}; + w.context.data = {projects:[project],jobs:[job],tasks:[{source_ref:job.source_ref,project:job.project,title:job.title,latest_run:job}],workers:[]}; + w.run('renderOverview(data)'); + w.nodes.board.querySelector('button').focus(); + w.run('renderOverview(data)'); + assert.equal(w.context.document.activeElement, w.nodes.board.querySelector('button')); + w.context.detail = {job, instruction:'Fix the parser', check_count:1, attempts:[],timeline:[],diagnostics:{id:'task'}}; + w.run('renderDetail(detail)'); + w.nodes['detail-content'].querySelector('details').open = true; + w.run('renderDetail(detail)'); + assert.equal(w.nodes['detail-content'].querySelector('details').open, true); +}); + +test('six task lanes exclude historical jobs; Runs history filters project and status', () => { + const w=workspace(); + w.context.data={projects:[project],tasks:[],workers:[],jobs:[{id:'old',title:'Historical Forge job',status:'succeeded',project:'parser'}]}; + w.run('renderOverview(data)'); + assert.equal(w.nodes.board.children.length,6); + assert.doesNotMatch(w.nodes.board.textContent,/Historical Forge job/); + assert.match(w.nodes.runs.textContent,/Run\/Job.*Historical Forge job/); + w.nodes['run-status'].value='failed';w.run('renderRuns()');assert.doesNotMatch(w.nodes.runs.textContent,/Historical Forge job/); + w.nodes['run-status'].value='';w.nodes['project-filter'].value='other';w.run('renderRuns()');assert.doesNotMatch(w.nodes.runs.textContent,/Historical Forge job/); + w.context.data.tasks=[{project:'parser',source_ref:'https://github.com/org/repo/issues/1',title:'Product backlog',issue:{number:1,state:'open',labels:[]}}, {project:'parser',source_ref:'https://github.com/org/repo/issues/2',title:'Scheduled issue',issue:{number:2,state:'open',labels:['ready-for-agent']}}]; + w.run('renderOverview(data)'); + assert.match(w.nodes.board.children[0].textContent,/Product backlog/); + assert.match(w.nodes.board.children[1].textContent,/Scheduled issue/); +}); + +test('unavailable GitHub backlog keeps linked local tasks and runs truthful', async () => { + const w=workspace(); + w.context.data={projects:[project],tasks:[{project:'parser',source_ref:'https://github.com/org/repo/issues/1',title:'Linked work',latest_run:{id:'run',status:'leased'}}],workers:[],jobs:[]}; + w.run("token='owner';renderOverview(data)");w.nodes['project-filter'].value='parser'; + w.context.fetch=async()=>({ok:false,status:502}); + await w.run('loadBacklog(true)'); + assert.match(w.nodes['backlog-notice'].textContent,/unavailable/i); + assert.match(w.nodes.board.children[2].textContent,/Linked work/); +}); + +test('task drawer groups runs and attempts with collapsed brief and labeled diagnostics', () => { + const w=workspace(); + const task={project:'parser',source_ref:'https://github.com/org/repo/issues/12',title:'Fix parser',issue:{number:12,body:'A long public issue brief',state:'open',labels:[]},latest_run:{status:'retry_wait'}}; + const run={job:{id:'job',title:'Fix parser',ordinal:1,project:'parser',status:'retry_wait',agent:'codex',worker_id:'slot-1',plugin_timeout_ms:900000},instruction:'Full execution instructions',check_count:1,diagnostics:{id:'job',base_sha:'abcdef'},timeline:[{type:'submitted',at:'2026-09-01T00:00:00Z'},{type:'leased'},{type:'retry_scheduled'}],attempts:[{id:'attempt',ordinal:1,status:'failed',failure_code:'execution_failed',failure_disposition:'retryable',worker_id:'slot-1',leased_at:'2026-09-01T00:00:00Z',completed_at:'2026-09-01T00:15:00Z',evidence:[{reason:'plugin_protocol_failed',duration_ms:900000}]}]}; + w.context.task=task;w.context.details=[run];w.run('renderTaskDetail(task,details)'); + const root=w.nodes['detail-content']; + assert.match(root.textContent,/Issue #12/);assert.match(root.textContent,/Ready/); + assert.match(root.textContent,/Run 1/);assert.match(root.textContent,/Attempt 1/); + assert.match(root.textContent,/Coding agent timed out after 15 min/); + assert.match(root.textContent,/Forge scheduled another attempt/); + function walk(node){return [node,...node.children.flatMap(walk)];} + const disclosures=walk(root).filter(n=>n.tagName==='details'); + assert.ok(disclosures.some(n=>n.children[0].textContent==='Full brief'&&!n.open)); + assert.ok(disclosures.some(n=>n.children[0].textContent.startsWith('Diagnostics')&&!n.open)); + assert.ok(walk(root).some(n=>n.tagName==='button'&&n.textContent==='Copy')); + assert.equal(walk(root).filter(n=>n.tagName==='pre').length,0); + assert.doesNotMatch(root.textContent,/"base_sha"|retry_scheduled|· leased/); +}); + +test('attempt outcomes are deterministic, bounded by reported timing, and do not invent retries', () => { + const w=workspace(); + assert.equal(w.run("attemptOutcome({status:'failed',failure_code:'execution_failed',failure_disposition:'retryable',evidence:[]},{status:'retry_wait'})"),'Coding agent did not finish; Forge scheduled another attempt.'); + assert.equal(w.run("evidenceSummary({reason:'scoped_check_passed',check_index:0,duration_ms:21000},{})"),'Check 1 passed in 21s.'); + assert.equal(w.run("evidenceSummary({reason:'plugin_protocol_failed',duration_ms:900000},{plugin_timeout_ms:900000})"),'Coding agent timed out after 15 min.'); + assert.doesNotMatch(w.run("evidenceSummary({reason:'plugin_protocol_failed',duration_ms:900000},{plugin_timeout_ms:3600000})"),/timed out/); + assert.equal(w.run("attemptOutcome({status:'succeeded',evidence:[{reason:'cleanup_failed'}]},{status:'succeeded'})"),'Work completed, but temporary workspace cleanup needs attention.'); + assert.doesNotMatch(w.run("attemptOutcome({status:'failed',failure_code:'execution_failed',failure_disposition:'retryable',evidence:[]},{status:'failed'})"),/scheduled another/); +}); + +test('starting a backlog issue prefills its exact URL, title and brief without a SHA', async () => { + const w=workspace();w.context.data={projects:[project],tasks:[],workers:[],jobs:[]}; + w.run('renderOverview(data)'); + w.context.task={project:'parser',title:'Fix Unicode parser',source_ref:'https://github.com/org/repo/issues/12',issue:{number:12,body:'Keep Unicode intact.\nAdd a focused check.',state:'open',labels:[]}}; + await w.run('startIssueTask(task)'); + assert.equal(w.nodes['task-project'].value,'parser'); + assert.equal(w.nodes['task-title'].value,'Fix Unicode parser'); + assert.equal(w.nodes['task-instruction'].value,'Keep Unicode intact.\nAdd a focused check.'); + assert.equal(w.nodes['task-source'].value,'https://github.com/org/repo/issues/12'); + assert.equal(w.nodes['task-modal'].open,true); + assert.equal(w.calls.length,0); +}); + +test('drawer humanizes run and delivery states, and marks a truncated issue brief', () => { + const w=workspace(); + w.context.detail={job:{id:'run',title:'Fix',status:'retry_wait',delivery:{phase:'retry_wait',ci_state:'pending'}},instruction:'brief',attempts:[],timeline:[],diagnostics:{}}; + w.run('renderDetail(detail)'); + assert.doesNotMatch(w.nodes['detail-content'].textContent,/retry_wait/); + w.context.task={title:'Fix',project:'parser',issue:{number:1,state:'open',body:'excerpt',body_truncated:true}}; + w.run('renderTaskDetail(task,[])'); + assert.match(w.nodes['detail-content'].textContent,/brief is truncated/i); +}); + +test('successful submission opens the submitted task even if fields change while pending', async () => { + const w=workspace();let resolve; + w.run("token='owner'; refresh=async()=>{}; openTaskDetail=task=>{globalThis.opened=task}; openDetail=id=>{globalThis.opened={id}}"); + w.context.fetch=()=>new Promise(done=>{resolve=done}); + for(const [id,value] of Object.entries({'task-project':'parser','task-title':'Original task','task-instruction':'Original brief','task-source':'https://github.com/org/repo/issues/12','task-preset':'go'}))w.nodes[id].value=value; + const pending=w.run('submitTask({preventDefault(){}})'); + w.nodes['task-source'].value='https://github.com/org/repo/issues/99';w.nodes['task-project'].value='changed'; + resolve({ok:true,status:201,json:async()=>({id:'new-run',status:'pending'})});await pending; + assert.equal(w.context.opened.source_ref,'https://github.com/org/repo/issues/12'); + assert.equal(w.context.opened.project,'parser');assert.equal(w.context.opened.title,'Original task'); +}); + +test('All projects fetches public feeds with at most three requests, dedupes and shares navigation state', async () => { + const w=workspace();const projects=['alpha','beta','gamma','delta'].map(id=>({...project,id})); + const task=id=>({project:id,source_ref:`https://github.com/org/${id}/issues/1`,title:`Issue ${id}`,issue:{number:1,state:'open',labels:[]}}); + w.context.data={projects:[...projects,{...project,id:'private',public_source:false}],tasks:[task('alpha')],jobs:[{id:'run',project:'alpha',title:'Alpha run',status:'pending'}],workers:[{id:'worker',pool:'coding',connected:true,occupied:false}]}; + w.run("token='owner';renderOverview(data)"); + let active=0,peak=0;const calls=[]; + w.context.fetch=async url=>{const id=url.split('/')[4];calls.push(id);active++;peak=Math.max(peak,active);await new Promise(resolve=>setImmediate(resolve));active--;return {ok:true,status:200,json:async()=>({tasks:[task(id),task(id)],available:true})};}; + await w.run('loadBacklog()'); + assert.deepEqual(calls.sort(),['alpha','beta','delta','gamma']);assert.ok(peak<=3);assert.equal(peak,3); + assert.equal(w.run('boardTasks().length'),4); + for(const id of calls)assert.match(w.nodes.board.textContent,new RegExp(`Issue ${id}`)); + const alpha=w.nodes['project-nav'].children.find(node=>node.dataset.project==='alpha'); + await alpha.listeners.click(); + assert.equal(w.nodes['project-filter'].value,'alpha');assert.match(w.nodes.board.textContent,/Issue alpha/);assert.doesNotMatch(w.nodes.board.textContent,/Issue beta/); + assert.match(w.nodes['project-info'].textContent,/Branch/);assert.match(w.nodes['backlog-notice'].textContent,/alpha/); + assert.equal(calls.length,4); + w.nodes['project-filter'].value='';await w.nodes['project-filter'].listeners.change(); + assert.match(w.nodes.board.textContent,/Issue beta/); + assert.equal(w.nodes['project-nav'].children[0].attributes['aria-current'],'true'); + await w.run('loadBacklog(true)');assert.equal(calls.length,8); +}); + +test('All projects keeps successful feeds on partial failure and fences old selections and locked sessions', async () => { + const w=workspace();w.context.data={projects:[{...project,id:'alpha'},{...project,id:'beta'}],tasks:[],jobs:[],workers:[]}; + w.run("token='owner';renderOverview(data)"); + w.context.fetch=async url=>url.includes('/beta/')?{ok:false,status:502}:{ok:true,status:200,json:async()=>({tasks:[{project:'alpha',source_ref:'https://github.com/org/repo/issues/1',title:'Alpha issue',issue:{state:'open',labels:[]}}]})}; + await w.run('loadBacklog()'); + assert.match(w.nodes.board.textContent,/Alpha issue/);assert.match(w.nodes['backlog-notice'].textContent,/unavailable.*beta/i);assert.ok(w.nodes['backlog-notice'].textContent.length<600); + const pendingResponses=[];w.context.fetch=()=>new Promise(done=>{pendingResponses.push(done)}); + const pending=w.run('loadBacklog(true)');await new Promise(done=>setImmediate(done)); + w.run('lockWorkspace()');for(const resolve of pendingResponses)resolve({ok:true,status:200,json:async()=>({tasks:[{title:'Stale issue'}]})});await pending; + assert.doesNotMatch(w.nodes.board.textContent,/Stale|Alpha issue/);assert.equal(w.run('backlogs.size'),0); +}); + +test('closed issue without a run shows GitHub completion, closure and lazy merged PR evidence without Start', async () => { + const w=workspace(); + w.context.task={project:'parser',source_ref:'https://github.com/org/repo/issues/53',title:'Completed task',issue:{number:53,state:'closed',labels:[],closed_at:'2026-08-29T20:44:18Z',closed_by:'kricha-lab-dev-worker[bot]',body:'Full brief'}}; + w.context.data={projects:[project],tasks:[w.context.task],jobs:[],workers:[]}; + const calls=[]; + w.context.fetch=async(url,options)=>{calls.push(url);assert.equal(options.headers.Authorization,'Bearer owner');return {ok:true,status:200,json:async()=>url.endsWith('/activity')?{available:true,merged_prs:[{number:55,url:'https://github.com/org/repo/pull/55',merged_at:'2026-08-29T20:44:17Z'}]}:{runs:[],total:0}}}; + w.run("token='owner';renderOverview(data)");assert.equal(calls.length,0); + w.run('openTaskDetail(task)');await new Promise(done=>setImmediate(done)); + const root=w.nodes['detail-content']; + assert.match(root.textContent,/Completed on GitHub\. No Forge execution record is linked; this task predates tracking or was completed outside Forge\./); + assert.match(root.textContent,/kricha-lab-dev-worker\[bot\]/);assert.match(root.textContent,/2026/); + assert.doesNotMatch(root.textContent,/Review the brief and start|No run started/); + assert.ok(root.querySelectorAll('a').some(a=>a.href==='https://github.com/org/repo/pull/55'&&a.textContent.includes('#55'))); + assert.ok(!root.querySelectorAll('button').some(b=>b.textContent.includes('Start'))); + assert.equal(root.querySelector('details').open,false); + await w.run('loadTaskDetail(task)');assert.equal(calls.filter(url=>url.endsWith('/activity')).length,1); +}); + +test('activity failure retains closed metadata and readable notice; open issues retain Start', async () => { + const w=workspace();w.context.task={project:'parser',source_ref:'https://github.com/org/repo/issues/53',title:'Completed',issue:{number:53,state:'closed',closed_by:'worker[bot]',closed_at:'2026-08-29T20:44:18Z'}}; + w.run("token='owner'"); + w.context.fetch=async url=>url.endsWith('/activity')?{ok:false,status:502}:{ok:true,status:200,json:async()=>({runs:[],total:0})}; + w.run('openTaskDetail(task)');await new Promise(done=>setImmediate(done)); + assert.match(w.nodes['detail-content'].textContent,/Additional GitHub activity unavailable/); + assert.match(w.nodes['detail-content'].textContent,/Completed on GitHub.*worker\[bot\]/); + w.run("task.issue.state='open';renderTaskDetail(task,[])"); + assert.ok(w.nodes['detail-content'].querySelectorAll('button').some(b=>b.textContent==='Start run')); +}); + +test('backlog cache expires after five minutes and late All projects responses cannot change a selected project', async () => { + const w=workspace();w.context.data={projects:[{...project,id:'alpha'},{...project,id:'beta'}],tasks:[],jobs:[],workers:[]}; + w.run("token='owner';renderOverview(data)"); + const task=id=>({project:id,source_ref:`https://github.com/org/${id}/issues/1`,title:`Issue ${id}`,issue:{number:1,state:'open',labels:[]}}); + const response=id=>({ok:true,status:200,json:async()=>({tasks:[task(id)]})}); + const pending=[];w.context.fetch=url=>new Promise(resolve=>pending.push({id:url.split('/')[4],resolve})); + const all=w.run('loadBacklog()');await new Promise(done=>setImmediate(done)); + const single=w.run("selectProject('beta')"); + for(const item of pending)item.resolve(response(item.id)); + w.context.fetch=async()=>response('beta');await Promise.all([all,single]); + assert.equal(w.nodes['project-filter'].value,'beta');assert.doesNotMatch(w.nodes.board.textContent,/Issue alpha/);assert.match(w.nodes.board.textContent,/Issue beta/); + assert.equal(w.run("backlogs.has('alpha')"),false); + w.nodes.board.querySelector('button').focus(); + let calls=0;w.context.fetch=async()=>{calls++;return response('beta')}; + await w.run('loadBacklog()');assert.equal(calls,0); + w.run("backlogs.get('beta').checkedAt=Date.now()-300001"); + await w.run('loadBacklog()');assert.equal(calls,1); + assert.equal(w.context.document.activeElement,w.nodes.board.querySelector('button')); +}); + +test('late issue activity cannot repaint another drawer or a locked workspace', async () => { + const w=workspace();w.context.task={project:'parser',source_ref:'https://github.com/org/repo/issues/53',title:'Completed',issue:{number:53,state:'closed'}}; + w.run("token='owner'");const pending=[]; + w.context.fetch=url=>url.endsWith('/activity')?new Promise(resolve=>pending.push(resolve)):Promise.resolve({ok:true,status:200,json:async()=>({runs:[],total:0})}); + w.run('openTaskDetail(task)');await new Promise(done=>setImmediate(done)); + w.nodes['detail-modal'].close(); + w.run('openTaskDetail(task)');await new Promise(done=>setImmediate(done)); + pending[0]({ok:true,status:200,json:async()=>({available:true,merged_prs:[{number:99,url:'https://github.com/org/repo/pull/99'}]})}); + await new Promise(done=>setImmediate(done));assert.doesNotMatch(w.nodes['detail-content'].textContent,/#99/); + w.run('lockWorkspace()');pending[1]({ok:true,status:200,json:async()=>({available:true,merged_prs:[{number:55,url:'https://github.com/org/repo/pull/55'}]})}); + await new Promise(done=>setImmediate(done));assert.equal(w.nodes['detail-content'].textContent,''); +}); + +test('Delivered changes explains fixture files before attempts and is GitHub evidence, not agent activity', async () => { + const w=workspace();w.context.task={project:'parser',source_ref:'https://github.com/org/repo/issues/53',title:'Completed',issue:{number:53,state:'closed'}}; + w.run("token='owner'"); + w.context.fetch=async url=>({ok:true,status:200,json:async()=>url.endsWith('/activity')?{available:true,merged_prs:[{number:55,url:'https://github.com/org/repo/pull/55',merged_at:'2026-08-29T20:44:17Z',details:{delivery_evidence:true,title:'Preserve Unicode',summary:'Normalize input so equivalent names match. ',changed_files:2,additions:18,deletions:3,files:[{filename:'parser/normalize.go',status:'modified',additions:12,deletions:3},{filename:'parser/normalize_test.go',status:'added',additions:6,deletions:0}]}}]}:{runs:[],total:0}}); + w.run('openTaskDetail(task)');await new Promise(done=>setImmediate(done)); + const root=w.nodes['detail-content'],text=root.textContent; + assert.match(text,/Delivered changes.*GitHub delivery evidence.*Preserve Unicode.*equivalent names match/); + assert.match(text,/2 files.*18.*3/);assert.match(text,/parser\/normalize.go.*parser\/normalize_test.go/); + assert.ok(text.indexOf('Delivered changes') { + const w=workspace(); + const report={summary:'Normalize equivalent names ',changes:['Compare normalized input ']}; + const attempt={id:'attempt2',ordinal:2,status:'succeeded',candidate_sha:'abc',agent_report:report,evidence:[{reason:'scoped_check_passed',check_index:0,duration_ms:21000}]}; + w.context.detail={job:{id:'run',ordinal:1,status:'succeeded',agent:'codex'},agent_report:report,instruction:'Brief',attempts:[{id:'attempt1',ordinal:1,status:'failed',failure_code:'execution_failed',evidence:[]},attempt],timeline:[],diagnostics:{}}; + w.run('renderDetail(detail)'); + const root=w.nodes['detail-content'],text=root.textContent; + assert.match(text,/Agent report.*Self-reported.*Attempt 2.*Normalize equivalent names/); + assert.ok(text.indexOf('Agent report') { + const w=workspace(); + for (const proof of [undefined,false,'true']) { + w.context.activity={data:{merged_prs:[{number:55,url:'https://github.com/org/repo/pull/55',merged_at:'2026-08-29T20:44:17Z',details:{delivery_evidence:proof,title:'Related work',summary:'Closes #53',files:[],changed_files:0,additions:0,deletions:0}}]}}; + w.run("renderIssueCompletion(byId('detail-content'),{state:'closed'},activity)"); + assert.match(w.nodes['detail-content'].textContent,/Related merged PR.*Related GitHub evidence.*Related work/); + assert.doesNotMatch(w.nodes['detail-content'].textContent,/Delivered changes|GitHub delivery evidence/); + } + }); + +for (const change of ['selection','close','lock','overlap','reopen','token','normal','error']) { + test(`latest-run prefill fences ${change}`, async () => { + const w=workspace(); + w.context.task={project:'parser',source_ref:'https://github.com/org/repo/issues/53',title:'Old issue',issue:{number:53,state:'open',body:''},latest_run:{id:'old',status:'failed'}}; + w.context.other={...w.context.task,title:'Current issue',source_ref:'https://github.com/org/repo/issues/54',issue:{number:54,state:'open',body:'New brief'},latest_run:null}; + w.context.data={projects:[project],tasks:[w.context.task,w.context.other],jobs:[],workers:[]}; + w.run("token='owner';renderOverview(data)"); + w.context.fetch=async url=>({ok:true,status:200,json:async()=>url.endsWith('/activity')?{available:true,merged_prs:[]}:{runs:[],total:0}}); + w.run('openTaskDetail(task)');await new Promise(done=>setImmediate(done)); + const pending=[];w.context.fetch=()=>new Promise(resolve=>pending.push(resolve)); + const first=w.run('startIssueTask(task)'); + if(change==='selection'||change==='error') w.run('openTaskDetail(other)'); + if(change==='close') w.nodes['detail-modal'].close(); + if(change==='reopen') {w.nodes['detail-modal'].close();w.run('openTaskDetail(task)');} + if(change==='lock') w.run('lockWorkspace()'); + if(change==='token') w.run("token='replacement'"); + let second; + if(change==='overlap') second=w.run('startIssueTask(task)'); + const before={drawer:w.nodes['detail-modal'].open,modal:w.nodes['task-modal'].open,title:w.nodes['task-title'].value,brief:w.nodes['task-instruction'].value,notice:w.nodes['detail-notice'].textContent}; + pending[0]({ok:change!=='error',status:change==='error'?502:200,json:async()=>({instruction:'Old fetched brief'})}); + await first; + if(change==='normal') { + assert.equal(w.nodes['task-modal'].open,true);assert.equal(w.nodes['detail-modal'].open,false); + assert.equal(w.nodes['task-instruction'].value,'Old fetched brief');assert.equal(w.nodes['task-source'].value,w.context.task.source_ref); + } else { + assert.deepEqual({drawer:w.nodes['detail-modal'].open,modal:w.nodes['task-modal'].open,title:w.nodes['task-title'].value,brief:w.nodes['task-instruction'].value,notice:w.nodes['detail-notice'].textContent},before); + } + if(second) {pending[1]({ok:true,status:200,json:async()=>({instruction:'Newest brief'})});await second;assert.equal(w.nodes['task-instruction'].value,'Newest brief');} + }); +} + +function deferred() { + let resolve, reject; + const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); + return {promise, resolve, reject}; +} + +for (const phase of ['fetch', 'json']) { + for (const outcome of ['success', 'failure']) { + for (const change of ['session', 'lock', 'close', 'different job', 'reopen', 'overlap', 'token', 'normal']) { + test(`loadDetail fences ${phase} ${outcome} after ${change}`, async () => { + const w = workspace(), waiting = deferred(), decoding = deferred(); + const detail = title => ({job:{id:'job',title,status:'pending'},instruction:title,attempts:[],timeline:[]}); + const response = title => ({ok:true,status:200,json:async()=>detail(title)}); + // Observe completion while exercising the real openDetail/loadDetail DOM path. + w.run("token='owner'; const realLoadDetail=loadDetail; loadDetail=id=>(globalThis.loading=realLoadDetail(id))"); + w.context.fetch = () => phase === 'fetch' ? waiting.promise : Promise.resolve({ + ok:true,status:200,json:()=>{decoding.resolve();return waiting.promise;} + }); + w.run("openDetail('job')"); + const old = w.context.loading; + if (phase === 'json') await decoding.promise; + w.context.fetch = async () => response('Current drawer'); + if (change === 'session') { + w.run("lockWorkspace(); session++; token='new-owner'; openDetail('job')"); + } + if (change === 'lock') w.run('lockWorkspace()'); + if (change === 'close') w.nodes['detail-modal'].close(); + if (change === 'different job') w.run("openDetail('other')"); + if (change === 'reopen') { w.nodes['detail-modal'].close(); w.run("openDetail('job')"); } + if (change === 'overlap') w.run("loadDetail('job')"); + if (change === 'token') w.run("token='replacement'"); + if (w.context.loading !== old) await w.context.loading; + w.nodes['detail-notice'].textContent = 'Current notice'; + const snapshot = () => ({content:w.nodes['detail-content'].textContent, + children:[...w.nodes['detail-content'].children],notice:w.nodes['detail-notice'].textContent, + open:w.nodes['detail-modal'].open,selection:w.run('selectedJob'),title:w.nodes['detail-title'].textContent}); + const before = snapshot(); + if (phase === 'fetch') waiting.resolve(outcome === 'success' ? response('Old drawer') : {ok:false,status:404}); + else if (outcome === 'success') waiting.resolve(detail('Old drawer')); + else waiting.reject(new Error('Body decoding failed')); + await old; + if (change !== 'normal') assert.deepEqual(snapshot(), before); + else if (outcome === 'success') { + assert.match(w.nodes['detail-content'].textContent, /Old drawer/); + assert.equal(w.nodes['detail-notice'].textContent, ''); + } else { + assert.equal(w.nodes['detail-content'].textContent, before.content); + assert.equal(w.nodes['detail-notice'].textContent, phase === 'fetch' ? 'Task not found.' : 'Body decoding failed'); + } + }); + } + } +} + +test('api rejects prior-session data after delayed JSON decoding', async () => { + const w = workspace(), body = deferred(), decoding = deferred(); + w.run("token='owner'"); + w.context.fetch = async () => ({ok:true,status:200,json:()=>{decoding.resolve();return body.promise;}}); + const pending = w.run("api('/v1/control/jobs/job')"); + const rejected = assert.rejects(pending, /Workspace session changed\./); + await decoding.promise; + w.run("lockWorkspace(); session++; token='new-owner'"); + body.resolve({secret:'old session data'}); + await rejected; +}); + +for (const phase of ['fetch', 'json']) { + test(`submission does not render a prior-session sentinel after delayed ${phase}`, async () => { + const w = workspace(), waiting = deferred(), decoding = deferred(); + w.run("token='owner'"); + w.context.fetch = () => phase === 'fetch' ? waiting.promise : Promise.resolve({ + ok:true,status:201,json:()=>{decoding.resolve();return waiting.promise;} + }); + const pending = w.run('submitTask({preventDefault(){}})'); + if (phase === 'json') await decoding.promise; + w.run("lockWorkspace(); session++; token='new-owner'"); + w.nodes['task-error'].textContent = 'Current task notice'; + w.context.fetch = async () => ({ok:true,status:200,json:async()=>({projects:[],workers:[],jobs:[]})}); + waiting.resolve(phase === 'fetch' ? {ok:true,status:201,json:async()=>({id:'old'})} : {id:'old'}); + await pending; + assert.equal(w.nodes['task-error'].textContent, 'Current task notice'); + assert.equal(w.nodes['detail-modal'].open, false); + }); +} diff --git a/internal/gate/gate.go b/internal/gate/gate.go index d462332..f30d16e 100644 --- a/internal/gate/gate.go +++ b/internal/gate/gate.go @@ -301,6 +301,14 @@ func (x *server) routes() http.Handler { _, _ = mac.Write([]byte("agent-forge/install-ready/v2\x00" + values[0] + "\x00" + x.options.ReleaseVersion + "\x00" + x.options.ReleaseCommit)) writeJSON(w, http.StatusOK, map[string]string{"commit": x.options.ReleaseCommit, "proof": base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), "status": "ready", "version": x.options.ReleaseVersion}) }) + m.HandleFunc("/app", getOnly(x.controlAsset)) + m.HandleFunc("/app/", getOnly(x.controlAsset)) + m.HandleFunc("/v1/control/projects/{id}/runs", x.ownerAuth(getOnly(x.controlSourceRuns))) + m.HandleFunc("/v1/control/projects/{id}/issues/{number}/activity", x.ownerAuth(getOnly(x.controlIssueActivity))) + m.HandleFunc("/v1/control/projects/{id}/issues", x.ownerAuth(getOnly(x.controlIssues))) + m.HandleFunc("/v1/control/overview", x.ownerAuth(getOnly(x.controlOverview))) + m.HandleFunc("/v1/control/jobs/{id}", x.ownerAuth(getOnly(x.controlDetail))) + m.HandleFunc("POST /v1/control/jobs", x.ownerAuth(x.controlSubmit)) m.HandleFunc("POST /v1/jobs", x.ownerAuth(x.submit)) m.HandleFunc("GET /v1/jobs/{id}", x.ownerAuth(x.getJob)) m.HandleFunc("GET /v1/jobs/{id}/status", x.ownerAuth(x.getJobStatus)) @@ -600,12 +608,17 @@ func (x *server) submitConfigured(w http.ResponseWriter, r *http.Request) { task.Repository = prepared x.log("repository_prepared", "repository_id", repository.ID, "base_sha", task.BaseSHA) } + x.persistConfiguredTask(w, task, *repository, string(in.SourceRef)) +} + +func (x *server) persistConfiguredTask(w http.ResponseWriter, task protocol.CodingTask, repository RepositoryRegistration, source string) { + var err error policy := x.config.resolvedPolicy(repository.WorkerPool, repository.Execution, repository.ID, repository.DefaultBranch) var job store.Job - if in.SourceRef == "" { + if source == "" { job, err = x.store.CreateCodingJobWithPolicy(task, policy) } else { - job, err = x.store.CreateCodingJobWithPolicyAndSource(task, policy, string(in.SourceRef)) + job, err = x.store.CreateCodingJobWithPolicyAndSource(task, policy, source) } if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "request failed"}) @@ -1118,6 +1131,10 @@ func (x *server) connect(w http.ResponseWriter, r *http.Request) { var terminalJob store.Job var resultErr error if m.Error != "" { + if m.Result != "" || m.CandidateSHA != "" { + reject() + return + } disposition, ok := failureDisposition(m.Error) if !ok || m.Disposition != "" && m.Disposition != string(disposition) { reject() @@ -1132,6 +1149,10 @@ func (x *server) connect(w http.ResponseWriter, r *http.Request) { reject() return } else if m.CandidateSHA != "" { + if _, err := protocol.DecodeAgentReport(m.Result); err != nil { + reject() + return + } if x.config != nil { repository, deliver := RepositoryRegistration{}, false if x.config.Delivery != nil && active.Task != nil { @@ -1142,13 +1163,13 @@ func (x *server) connect(w http.ResponseWriter, r *http.Request) { var delivery store.Delivery delivery, resultErr = x.deliveryForCandidate(ctx, *active, m.CandidateSHA) if resultErr == nil { - terminalJob, resultErr = x.store.CompleteCandidateDeliveryLeaseAt(m.JobID, m.AttemptID, effectiveID, generation, delivery, at) + terminalJob, resultErr = x.store.CompleteCandidateDeliveryReportLeaseAt(m.JobID, m.AttemptID, effectiveID, generation, delivery, m.Result, at) } } else { - terminalJob, resultErr = x.store.CompleteCandidateLeaseAt(m.JobID, m.AttemptID, effectiveID, generation, m.CandidateSHA, at) + terminalJob, resultErr = x.store.CompleteCandidateReportLeaseAt(m.JobID, m.AttemptID, effectiveID, generation, m.CandidateSHA, m.Result, at) } } else { - terminalJob, resultErr = x.store.CompleteCandidateAt(m.JobID, m.AttemptID, m.CandidateSHA, at) + terminalJob, resultErr = x.store.CompleteCandidateReportAt(m.JobID, m.AttemptID, m.CandidateSHA, m.Result, at) } } else { if x.config != nil { diff --git a/internal/gate/public_source.go b/internal/gate/public_source.go index 6bb62dc..1c30dfe 100644 --- a/internal/gate/public_source.go +++ b/internal/gate/public_source.go @@ -89,10 +89,19 @@ func publicRepositoryPath(root string, source publicSource) string { } func provisionPublicRepository(parent context.Context, config Config, repository RepositoryRegistration, base string) (string, error) { - source, err := canonicalPublicGitHubURL(repository.RepositoryURL) - if err != nil || protocol.ValidateBranchName(repository.DefaultBranch) != nil || protocol.ValidateBaseSHA(base) != nil || config.PublicRepositoryRoot == "" || config.GitExecutable == "" { + if protocol.ValidateBaseSHA(base) != nil { return "", preparationFailure(protocol.EvidenceReasonSourcePolicyInvalid, false) } + path, _, err := preparePublicRepository(parent, config, repository, base) + return path, err +} + +// An empty base resolves the fetched default-branch head while holding the preparation lock. +func preparePublicRepository(parent context.Context, config Config, repository RepositoryRegistration, base string) (string, string, error) { + source, err := canonicalPublicGitHubURL(repository.RepositoryURL) + if err != nil || protocol.ValidateBranchName(repository.DefaultBranch) != nil || base != "" && protocol.ValidateBaseSHA(base) != nil || config.PublicRepositoryRoot == "" || config.GitExecutable == "" { + return "", "", preparationFailure(protocol.EvidenceReasonSourcePolicyInvalid, false) + } publicProvisionMu.Lock() defer publicProvisionMu.Unlock() target := publicRepositoryPath(config.PublicRepositoryRoot, source) @@ -101,10 +110,10 @@ func provisionPublicRepository(parent context.Context, config Config, repository if _, err := os.Lstat(target); errors.Is(err, os.ErrNotExist) { temp, err := os.MkdirTemp(config.PublicRepositoryRoot, ".forge-clone-") if err != nil { - return "", preparationFailure(protocol.EvidenceReasonRepositoryStateUnsafe, false) + return "", "", preparationFailure(protocol.EvidenceReasonRepositoryStateUnsafe, false) } if err := os.Remove(temp); err != nil { - return "", preparationFailure(protocol.EvidenceReasonRepositoryStateUnsafe, false) + return "", "", preparationFailure(protocol.EvidenceReasonRepositoryStateUnsafe, false) } keep := false defer func() { @@ -113,29 +122,35 @@ func provisionPublicRepository(parent context.Context, config Config, repository } }() if _, err := publicGitRunner(parent, config.GitExecutable, "", timeout, outputBytes, "clone", "--bare", "--template=", "--no-tags", "--single-branch", "--branch", repository.DefaultBranch, "--", remote, temp); err != nil { - return "", preparationFailure(protocol.EvidenceReasonCloneFailed, true) + return "", "", preparationFailure(protocol.EvidenceReasonCloneFailed, true) } if _, err := publicGitRunner(parent, config.GitExecutable, temp, timeout, outputBytes, "config", "--local", "core.hooksPath", "/dev/null"); err != nil || validatePublicRepository(parent, config.GitExecutable, temp, remote, timeout, outputBytes) != nil || os.Rename(temp, target) != nil { - return "", preparationFailure(protocol.EvidenceReasonRepositoryStateUnsafe, false) + return "", "", preparationFailure(protocol.EvidenceReasonRepositoryStateUnsafe, false) } keep = true } else if err != nil { - return "", preparationFailure(protocol.EvidenceReasonRepositoryStateUnsafe, false) + return "", "", preparationFailure(protocol.EvidenceReasonRepositoryStateUnsafe, false) } if validatePublicRepository(parent, config.GitExecutable, target, remote, timeout, outputBytes) != nil { - return "", preparationFailure(protocol.EvidenceReasonRepositoryStateUnsafe, false) + return "", "", preparationFailure(protocol.EvidenceReasonRepositoryStateUnsafe, false) } refspec := "+refs/heads/" + repository.DefaultBranch + ":refs/heads/" + repository.DefaultBranch if _, err := publicGitRunner(parent, config.GitExecutable, target, timeout, outputBytes, "fetch", "--no-tags", "--prune", "origin", refspec); err != nil { - return "", preparationFailure(protocol.EvidenceReasonFetchFailed, true) + return "", "", preparationFailure(protocol.EvidenceReasonFetchFailed, true) + } + if base == "" { + base, err = publicGitRunner(parent, config.GitExecutable, target, timeout, outputBytes, "rev-parse", "--verify", "refs/heads/"+repository.DefaultBranch+"^{commit}") + if err != nil || protocol.ValidateBaseSHA(base) != nil { + return "", "", preparationFailure(protocol.EvidenceReasonBaseUnavailable, false) + } } if _, err := publicGitRunner(parent, config.GitExecutable, target, timeout, outputBytes, "cat-file", "-e", base+"^{commit}"); err != nil { - return "", preparationFailure(protocol.EvidenceReasonBaseUnavailable, false) + return "", "", preparationFailure(protocol.EvidenceReasonBaseUnavailable, false) } if _, err := publicGitRunner(parent, config.GitExecutable, target, timeout, outputBytes, "merge-base", "--is-ancestor", base, "refs/heads/"+repository.DefaultBranch); err != nil { - return "", preparationFailure(protocol.EvidenceReasonBaseUnavailable, false) + return "", "", preparationFailure(protocol.EvidenceReasonBaseUnavailable, false) } - return target, nil + return target, base, nil } func validatePublicRepository(parent context.Context, executable, path, remote string, timeout time.Duration, outputBytes int64) error { diff --git a/internal/gate/public_source_test.go b/internal/gate/public_source_test.go index b0b399d..9cb18e2 100644 --- a/internal/gate/public_source_test.go +++ b/internal/gate/public_source_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "agent-forge/internal/protocol" "agent-forge/internal/store" "agent-forge/internal/worker" ) @@ -185,10 +186,11 @@ func TestPublicSourceGateWorkerE2E(t *testing.T) { } plugin := `import json,pathlib,sys i=json.loads(sys.stdin.readline()) -print(json.dumps({"version":"v1","id":i["id"],"type":"initialized","capabilities":["workspace_edit","progress","cancel","commit_subject"]},separators=(",",":")),flush=True) +assert "agent_report" in i["capabilities"] +print(json.dumps({"version":"v1","id":i["id"],"type":"initialized","capabilities":["workspace_edit","progress","cancel","commit_subject","agent_report"]},separators=(",",":")),flush=True) r=json.loads(sys.stdin.readline()) pathlib.Path(r["workspace"],"file").write_text("worker\n") -print(json.dumps({"version":"v1","id":i["id"],"type":"result","commit_subject":"test: worker candidate"},separators=(",",":")),flush=True)` +print(json.dumps({"version":"v1","id":i["id"],"type":"result","commit_subject":"test: worker candidate","summary":"Update the fixture file","changes":["Replace the fixture contents with the worker result"]},separators=(",",":")),flush=True)` workerBody, _ := json.Marshal(map[string]any{ "version": 1, "gate_url": "ws" + strings.TrimPrefix(server.URL, "http"), "id": "worker-1", "token_env": "FORGE_WORKER_TOKEN", "heartbeat_interval": "10ms", "concurrency": 1, "repository_roots": []string{repositories}, "worktree_root": worktrees, "runtime_root": runtime, "repositories": []any{}, @@ -236,10 +238,15 @@ print(json.dumps({"version":"v1","id":i["id"],"type":"result","commit_subject":" } time.Sleep(10 * time.Millisecond) } + report, reportErr := protocol.DecodeAgentReport(job.Result) + attempts, attemptErr := s.Attempts(job.ID) + if reportErr != nil || report == nil || report.Summary != "Update the fixture file" || attemptErr != nil || len(attempts) != 1 || attempts[0].Result != job.Result { + t.Fatal("end-to-end report lost", job.Result, reportErr) + } if job.Status != "succeeded" || job.CandidateSHA == "" || job.Task == nil || job.Task.Repository == "" { t.Fatalf("job = %#v, %v", job, err) } - attempts, err := s.Attempts(job.ID) + attempts, err = s.Attempts(job.ID) if err != nil || len(attempts) != 1 { t.Fatalf("attempts = %#v, %v", attempts, err) } diff --git a/internal/pluginprotocol/protocol.go b/internal/pluginprotocol/protocol.go index c14a602..8fa7938 100644 --- a/internal/pluginprotocol/protocol.go +++ b/internal/pluginprotocol/protocol.go @@ -1,6 +1,8 @@ package pluginprotocol import ( + "agent-forge/internal/configjson" + "agent-forge/internal/protocol" "bufio" "bytes" "context" @@ -31,6 +33,7 @@ const ( Progress Capability = "progress" Cancel Capability = "cancel" CommitSubject Capability = "commit_subject" + AgentReport Capability = "agent_report" ) type Limits struct { @@ -59,6 +62,7 @@ type Request struct { } type Result struct { + Report *protocol.AgentReport Output string CommitSubject *string } @@ -108,6 +112,7 @@ type workspaceResult struct { ID string `json:"id"` Type string `json:"type"` CommitSubject *string `json:"commit_subject,omitempty"` + *protocol.AgentReport } type progressFrame struct { @@ -214,11 +219,22 @@ func exchangeContext(ctx context.Context, dst io.Writer, src io.Reader, request } return Result{Output: terminal.Output}, nil } + if !contains(ready.Capabilities, AgentReport) { + var legacy struct { + Version string `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + CommitSubject *string `json:"commit_subject,omitempty"` + } + if decodeFrame(body, &legacy) != nil { + return Result{}, errors.New("invalid result frame") + } + } var terminal workspaceResult - if err := decodeFrame(body, &terminal); err != nil || ValidateCommitSubject(terminal.CommitSubject, contains(ready.Capabilities, CommitSubject)) != nil { + if err := configjson.Decode(body, &terminal); err != nil || protocol.ValidateAgentReport(terminal.AgentReport) != nil || ValidateCommitSubject(terminal.CommitSubject, contains(ready.Capabilities, CommitSubject)) != nil { return Result{}, errors.New("invalid result frame") } - return Result{CommitSubject: terminal.CommitSubject}, nil + return Result{CommitSubject: terminal.CommitSubject, Report: terminal.AgentReport}, nil default: return Result{}, errors.New("invalid frame order") } @@ -344,10 +360,13 @@ func Serve(in io.Reader, out io.Writer, supported []Capability, handler Handler) } return writeFrame(out, textResult{Version, init.ID, "result", result.Output}) } - if err := ValidateCommitSubject(result.CommitSubject, contains(selected, CommitSubject)); err != nil { + if !contains(selected, AgentReport) { + result.Report = nil + } + if err := ValidateCommitSubject(result.CommitSubject, contains(selected, CommitSubject)); err != nil || protocol.ValidateAgentReport(result.Report) != nil { return writeFrame(out, failure{Version, init.ID, "failure", "execution_failed"}) } - return writeFrame(out, workspaceResult{Version, init.ID, "result", result.CommitSubject}) + return writeFrame(out, workspaceResult{Version: Version, ID: init.ID, Type: "result", CommitSubject: result.CommitSubject, AgentReport: result.Report}) } func ValidateCommitSubject(subject *string, negotiated bool) error { @@ -460,7 +479,7 @@ func validID(id string) bool { func validCapabilities(capabilities []Capability) bool { seen := map[Capability]bool{} for _, capability := range capabilities { - if !contains([]Capability{Text, WorkspaceEdit, Progress, Cancel, CommitSubject}, capability) || seen[capability] { + if !contains([]Capability{Text, WorkspaceEdit, Progress, Cancel, CommitSubject, AgentReport}, capability) || seen[capability] { return false } seen[capability] = true diff --git a/internal/pluginprotocol/report_test.go b/internal/pluginprotocol/report_test.go new file mode 100644 index 0000000..f29dc96 --- /dev/null +++ b/internal/pluginprotocol/report_test.go @@ -0,0 +1,55 @@ +package pluginprotocol + +import ( + "io" + "strings" + "testing" +) + +func TestWorkspaceOptionalAgentReport(t *testing.T) { + id := strings.Repeat("a", 32) + ready := `{"version":"v1","id":"` + id + `","type":"initialized","capabilities":["workspace_edit","commit_subject","agent_report"]}` + "\n" + for _, fields := range []string{``, `,"summary":"Normalize input","changes":["Handle equivalent Unicode names"]`} { + wire := ready + `{"version":"v1","id":"` + id + `","type":"result","commit_subject":"fix: normalize"` + fields + "}\n" + result, err := Exchange(io.Discard, strings.NewReader(wire), Request{ID: id, Operation: WorkspaceEdit, Workspace: "/work", Instruction: "fix", TimeoutMS: 1000}, []Capability{WorkspaceEdit, CommitSubject, Capability("agent_report")}) + if fields != "" && (result.Report == nil || result.Report.Summary != "Normalize input" || len(result.Report.Changes) != 1) { + t.Fatal("missing typed report") + } + if err != nil || result.CommitSubject == nil { + t.Fatalf("optional report rejected: %v", err) + } + } +} + +func TestWorkspaceRejectsMalformedAgentReports(t *testing.T) { + id := strings.Repeat("a", 32) + ready := `{"version":"v1","id":"` + id + `","type":"initialized","capabilities":["workspace_edit","agent_report"]}` + "\n" + for _, fields := range []string{ + `"summary":"x"`, `"changes":["x"]`, `"summary":null,"changes":null`, + `"summary":" x","changes":["x"]`, `"summary":"x","changes":[""]`, + `"summary":"x\nsecret","changes":["x"]`, `"summary":"x","changes":["\u202e"]`, + `"summary":"\ud800","changes":["x"]`, `"summary":"x","changes":["x"],"private":"secret"`, + `"summary":"` + strings.Repeat("x", 1025) + `","changes":["x"]`, + `"summary":"x","changes":["` + strings.Repeat("x", 257) + `"]`, + `"summary":"x","changes":[` + strings.Repeat(`"x",`, 12) + `"x"]`, + `"summary":"x","summary":"y","changes":["x"]`, + } { + wire := ready + `{"version":"v1","id":"` + id + `","type":"result",` + fields + "}\n" + if _, err := Exchange(io.Discard, strings.NewReader(wire), Request{ID: id, Operation: WorkspaceEdit, Workspace: "/work", Instruction: "fix", TimeoutMS: 1000}, []Capability{WorkspaceEdit, AgentReport}); err == nil { + t.Fatalf("invalid report accepted: %q", fields) + } + } +} + +func TestUnnegotiatedReportRejected(t *testing.T) { + id := strings.Repeat("a", 32) + for _, offered := range [][]Capability{{WorkspaceEdit, CommitSubject}, {WorkspaceEdit, CommitSubject, AgentReport}} { + for _, fields := range []string{``, `,"summary":"x","changes":["x"]`, `,"summary":null`, `,"changes":null`} { + wire := `{"version":"v1","id":"` + id + `","type":"initialized","capabilities":["workspace_edit","commit_subject"]}` + "\n" + `{"version":"v1","id":"` + id + `","type":"result","commit_subject":"fix: legacy"` + fields + "}\n" + _, err := Exchange(io.Discard, strings.NewReader(wire), Request{ID: id, Operation: WorkspaceEdit, Workspace: "/work", Instruction: "fix", TimeoutMS: 1000}, offered) + if (err != nil) != (fields != "") { + t.Fatalf("fields=%q err=%v", fields, err) + } + } + } +} diff --git a/internal/protocol/agent_report.go b/internal/protocol/agent_report.go new file mode 100644 index 0000000..e949b2e --- /dev/null +++ b/internal/protocol/agent_report.go @@ -0,0 +1,60 @@ +package protocol + +import ( + "encoding/json" + "errors" + "strings" + "unicode" + "unicode/utf8" + + "agent-forge/internal/configjson" +) + +const MaxAgentReportBytes = 25 << 10 // Includes worst-case JSON escaping of 4 KiB of report text. + +type AgentReport struct { + Summary string `json:"summary"` + Changes []string `json:"changes"` +} + +func ValidateAgentReport(report *AgentReport) error { + if report == nil { + return nil + } + valid := func(value string, limit int) bool { + return value != "" && len(value) <= limit && utf8.ValidString(value) && strings.TrimSpace(value) == value && !strings.ContainsFunc(value, func(r rune) bool { + return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || r == '\u2028' || r == '\u2029' + }) + } + if !valid(report.Summary, 1024) || len(report.Changes) < 1 || len(report.Changes) > 12 { + return errors.New("invalid agent report") + } + for _, change := range report.Changes { + if !valid(change, 256) { + return errors.New("invalid agent report") + } + } + return nil +} + +func EncodeAgentReport(report *AgentReport) (string, error) { + if err := ValidateAgentReport(report); err != nil { + return "", err + } + if report == nil { + return "", nil + } + body, err := json.Marshal(report) + return string(body), err +} + +func DecodeAgentReport(value string) (*AgentReport, error) { + if value == "" { + return nil, nil + } + var report AgentReport + if len(value) > MaxAgentReportBytes || configjson.Decode([]byte(value), &report) != nil || ValidateAgentReport(&report) != nil { + return nil, errors.New("invalid agent report") + } + return &report, nil +} diff --git a/internal/store/candidate_report_test.go b/internal/store/candidate_report_test.go new file mode 100644 index 0000000..3faa00d --- /dev/null +++ b/internal/store/candidate_report_test.go @@ -0,0 +1,130 @@ +package store + +import ( + "agent-forge/internal/protocol" + "testing" + "time" +) + +const candidateReport = `{"summary":"Normalize input","changes":["Handle equivalent Unicode names"]}` + +func TestCandidateReportPersistsWithMatchingAttempt(t *testing.T) { + for _, mode := range []string{"legacy", "owned", "delivery"} { + t.Run(mode, func(t *testing.T) { + s := testStore(t) + at := time.Now().UTC() + task := protocol.CodingTask{RepositoryID: "agent-forge", BaseSHA: strings40("a"), Instruction: "edit"} + var job Job + var err error + if mode == "legacy" { + job, err = s.CreateCodingJob(task) + } else { + job, err = s.CreateCodingJobWithPolicy(task, testResolvedPolicy("coding")) + } + if err != nil { + t.Fatal(err) + } + var lease Lease + var ok bool + if mode == "legacy" { + lease, ok, err = s.LeaseNext("worker-1") + } else { + if err = s.ClaimWorkerSlot("worker-1", 0, "worker-1", "coding", testGeneration, at); err != nil { + t.Fatal(err) + } + lease, ok, err = s.LeaseNextForPool("worker-1", "coding", testGeneration, at) + } + if err != nil || !ok { + t.Fatal("lease", err) + } + complete := func(report string) (Job, error) { + var got Job + var err error + switch mode { + case "legacy": + got, err = s.CompleteCandidateReportAt(job.ID, lease.AttemptID, strings40("b"), report, at.Add(time.Second)) + case "owned": + got, err = s.CompleteCandidateReportLeaseAt(job.ID, lease.AttemptID, "worker-1", testGeneration, strings40("b"), report, at.Add(time.Second)) + case "delivery": + delivery := Delivery{JobID: job.ID, AttemptID: lease.AttemptID, CandidateSHA: strings40("b"), ExpectedTreeSHA: strings40("c"), ParentSHA: strings40("a"), CandidateRef: "refs/agent-forge/candidates/" + job.ID + "/" + lease.AttemptID, RepositoryID: "agent-forge", RepositoryURL: "https://github.com/org/repo.git", DefaultBranch: "main", Branch: "forge/" + job.ID, PRTitle: "Normalize input", MaxAttempts: 3} + got, err = s.CompleteCandidateDeliveryReportLeaseAt(job.ID, lease.AttemptID, "worker-1", testGeneration, delivery, report, at.Add(time.Second)) + } + return got, err + } + + for _, bad := range []string{`{"summary":"secret","changes":[]}`, `{"summary":"x","changes":["x"],"private":"secret"}`, `not-json-secret`} { + if _, err := complete(bad); err == nil { + t.Fatal("malformed report mutated candidate state") + } + before, _ := s.Job(job.ID) + attempts, _ := s.Attempts(job.ID) + if before.Status != "leased" || before.Result != "" || before.CandidateSHA != "" || attempts[0].Status != "leased" || attempts[0].Result != "" { + t.Fatal("invalid report partially persisted") + } + } + got, err := complete(candidateReport) + + attempts, _ := s.Attempts(job.ID) + if err != nil || got.Result != candidateReport || len(attempts) != 1 || attempts[0].Result != candidateReport || attempts[0].ID != lease.AttemptID { + t.Fatalf("report lost: %v job=%q attempts=%v", err, got.Result, attempts) + } + if mode == "delivery" { + if _, ok, err := s.ClaimDelivery(at.Add(2 * time.Second)); err != nil || !ok { + t.Fatal(err) + } + if err := s.RecoverDeliveries(at.Add(3 * time.Second)); err != nil { + t.Fatal(err) + } + if _, ok, err := s.ClaimDelivery(at.Add(4 * time.Second)); err != nil || !ok { + t.Fatal(err) + } + if err := s.CompleteDelivery(job.ID, strings40("d"), at.Add(5*time.Second)); err != nil { + t.Fatal(err) + } + final, _ := s.Job(job.ID) + attempts, _ = s.Attempts(job.ID) + if final.Result != candidateReport || attempts[0].Result != candidateReport { + t.Fatal("delivery erased report") + } + } + }) + } +} + +func TestCandidateReportRetryIsolation(t *testing.T) { + s := testStore(t) + at := time.Now().UTC() + job, err := s.CreateCodingJobWithPolicy(protocol.CodingTask{RepositoryID: "agent-forge", BaseSHA: strings40("a"), Instruction: "edit"}, testResolvedPolicy("coding")) + if err != nil { + t.Fatal(err) + } + if err = s.ClaimWorkerSlot("worker-1", 0, "worker-1", "coding", testGeneration, at); err != nil { + t.Fatal(err) + } + first, ok, err := s.LeaseNextForPool("worker-1", "coding", testGeneration, at) + if err != nil || !ok { + t.Fatal(err) + } + if _, err = s.FailLeaseAt(job.ID, first.AttemptID, "worker-1", testGeneration, "execution_failed", RetryableFailure, at.Add(time.Second)); err != nil { + t.Fatal(err) + } + second, ok, err := s.LeaseNextForPool("worker-1", "coding", testGeneration, at.Add(10*time.Second)) + if err != nil || !ok { + t.Fatal(err) + } + if _, err = s.CompleteCandidateReportLeaseAt(job.ID, first.AttemptID, "worker-1", testGeneration, strings40("b"), candidateReport, at.Add(11*time.Second)); err == nil { + t.Fatal("stale attempt report accepted") + } + before, _ := s.Job(job.ID) + attempts, _ := s.Attempts(job.ID) + if before.Result != "" || attempts[0].Result != "" || attempts[1].Result != "" { + t.Fatal("report carried into retry") + } + if _, err = s.CompleteCandidateReportLeaseAt(job.ID, second.AttemptID, "worker-1", testGeneration, strings40("b"), candidateReport, at.Add(11*time.Second)); err != nil { + t.Fatal(err) + } + attempts, _ = s.Attempts(job.ID) + if attempts[0].Result != "" || attempts[1].Result != candidateReport || attempts[1].ID != second.AttemptID { + t.Fatal("report bound to wrong attempt") + } +} diff --git a/internal/store/control.go b/internal/store/control.go new file mode 100644 index 0000000..be9cb02 --- /dev/null +++ b/internal/store/control.go @@ -0,0 +1,139 @@ +package store + +import ( + "context" + "time" +) + +// ControlWorker describes a recorded runtime slot; only leased jobs occupy it. +type ControlWorker struct { + ID string `json:"id"` + BaseID string `json:"base_id"` + Slot int `json:"slot"` + Pool string `json:"pool"` + Connected bool `json:"connected"` + LastSeen *time.Time `json:"last_seen"` + ActiveJobID string `json:"active_job_id,omitempty"` + Agent string `json:"agent,omitempty"` + Occupied bool `json:"occupied"` +} + +func (s *Store) ControlWorkers(ctx context.Context) ([]ControlWorker, bool, error) { + rows, err := s.db.QueryContext(ctx, `SELECT w.id,COALESCE(w.base_worker_id,w.id),COALESCE(w.slot,0),COALESCE(w.worker_pool,''),w.connected,w.last_seen,COALESCE(j.id,''),COALESCE(json_extract(NULLIF(j.resolved_policy,''),'$.execution.plugin_id'),'') FROM workers w LEFT JOIN jobs j ON j.worker_id=w.id AND j.status='leased' ORDER BY w.connected DESC,w.last_seen DESC,w.id LIMIT 257`) + if err != nil { + return nil, false, err + } + defer rows.Close() + out := []ControlWorker{} + for rows.Next() { + var w ControlWorker + var stamp string + if err := rows.Scan(&w.ID, &w.BaseID, &w.Slot, &w.Pool, &w.Connected, &stamp, &w.ActiveJobID, &w.Agent); err != nil { + return nil, false, err + } + at, err := time.Parse(time.RFC3339Nano, stamp) + if err != nil { + return nil, false, err + } + w.LastSeen = &at + w.Occupied = w.ActiveJobID != "" + out = append(out, w) + } + if err := rows.Err(); err != nil { + return nil, false, err + } + truncated := len(out) > 256 + if truncated { + out = out[:256] + } + return out, truncated, nil +} + +// ControlAgent reads the pinned policy without returning its environment or paths. +func (s *Store) ControlAgent(ctx context.Context, jobID string) (string, int64, error) { + var agent string + var timeout int64 + err := s.db.QueryRowContext(ctx, `SELECT COALESCE(json_extract(NULLIF(resolved_policy,''),'$.execution.plugin_id'),''),COALESCE(json_extract(NULLIF(resolved_policy,''),'$.execution.plugin_timeout_nanos'),0)/1000000 FROM jobs WHERE id=?`, jobID).Scan(&agent, &timeout) + return agent, timeout, err +} + +func (s *Store) ControlAttempts(jobID string) ([]Attempt, bool, error) { + attempts, err := s.attempts(jobID, 101) + truncated := len(attempts) > 100 + if truncated { + attempts = attempts[:100] + } + return attempts, truncated, err +} + +// ControlRunRef is a bounded projection; ordinals describe runs, not attempts. +type ControlRunRef struct { + ID string + Count int + Ordinal int +} + +// accept must only inspect the projection: the Store connection is held during iteration. +// The bound applies after acceptance so non-task groups cannot starve valid tasks. +func (s *Store) ControlTaskRuns(ctx context.Context, accept func(project, source string) bool) ([]ControlRunRef, bool, error) { + rows, err := s.db.QueryContext(ctx, `WITH linked AS ( + SELECT id,created_at,source_ref,COALESCE(json_extract(NULLIF(task_json,''),'$.repository_id'),'') AS project,COUNT(*) OVER (PARTITION BY COALESCE(json_extract(NULLIF(task_json,''),'$.repository_id'),''),source_ref) AS total, + ROW_NUMBER() OVER (PARTITION BY COALESCE(json_extract(NULLIF(task_json,''),'$.repository_id'),''),source_ref ORDER BY created_at DESC,id DESC) AS rank + FROM jobs WHERE source_ref<>'' + ) SELECT id,total,project,source_ref FROM linked WHERE rank=1 ORDER BY created_at DESC,id DESC`) + if err != nil { + return nil, false, err + } + defer rows.Close() + refs := []ControlRunRef{} + for rows.Next() { + if err := ctx.Err(); err != nil { + return nil, false, err + } + var ref ControlRunRef + var project, source string + if err := rows.Scan(&ref.ID, &ref.Count, &project, &source); err != nil { + return nil, false, err + } + if !accept(project, source) { + continue + } + ref.Ordinal = ref.Count + refs = append(refs, ref) + if len(refs) == 101 { + break + } + } + if err := rows.Err(); err != nil { + return nil, false, err + } + if err := ctx.Err(); err != nil { + return nil, false, err + } + truncated := len(refs) > 100 + if truncated { + refs = refs[:100] + } + return refs, truncated, nil +} + +func (s *Store) ControlSourceRuns(ctx context.Context, project, source string) ([]ControlRunRef, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,COUNT(*) OVER (),ROW_NUMBER() OVER (ORDER BY created_at,id) FROM jobs + WHERE COALESCE(json_extract(NULLIF(task_json,''),'$.repository_id'),'')=? AND source_ref=? ORDER BY created_at DESC,id DESC LIMIT 20`, project, source) + if err != nil { + return nil, err + } + defer rows.Close() + refs := []ControlRunRef{} + for rows.Next() { + var ref ControlRunRef + if err := rows.Scan(&ref.ID, &ref.Count, &ref.Ordinal); err != nil { + return nil, err + } + refs = append(refs, ref) + } + for i, j := 0, len(refs)-1; i < j; i, j = i+1, j-1 { + refs[i], refs[j] = refs[j], refs[i] + } + return refs, rows.Err() +} diff --git a/internal/store/control_test.go b/internal/store/control_test.go new file mode 100644 index 0000000..3016637 --- /dev/null +++ b/internal/store/control_test.go @@ -0,0 +1,35 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestControlTaskRunsIterationCancellationReleasesConnection(t *testing.T) { + s := testStore(t) + for _, source := range []string{"https://github.com/org/repo/issues/1", "https://github.com/org/repo/issues/2"} { + if _, err := s.CreateJobWithSource("task", source); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + calls := 0 + refs, truncated, err := s.ControlTaskRuns(ctx, func(project, source string) bool { calls++; cancel(); return false }) + if !errors.Is(err, context.Canceled) || len(refs) != 0 || truncated || calls == 0 { + t.Fatalf("refs=%v truncated=%v err=%v calls=%d", refs, truncated, err, calls) + } + // A cancelled query must release the sole connection, including its error path. + check, done := context.WithTimeout(context.Background(), time.Second) + defer done() + refs, truncated, err = s.ControlTaskRuns(check, func(string, string) bool { return true }) + if err != nil || len(refs) != 2 || truncated { + t.Fatalf("after cancellation: %v %v %v", refs, truncated, err) + } + _, _, err = s.ControlTaskRuns(ctx, func(string, string) bool { t.Fatal("cancelled query iterated"); return true }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled query: %v", err) + } +} diff --git a/internal/store/lifecycle_policy.go b/internal/store/lifecycle_policy.go index 75c9bcc..ee66cf4 100644 --- a/internal/store/lifecycle_policy.go +++ b/internal/store/lifecycle_policy.go @@ -62,6 +62,10 @@ func (s *Store) CompleteLeaseAt(jobID, attemptID, slot, generation, result strin } func (s *Store) CompleteCandidateLeaseAt(jobID, attemptID, slot, generation, candidateSHA string, at time.Time) (Job, error) { + return s.CompleteCandidateReportLeaseAt(jobID, attemptID, slot, generation, candidateSHA, "", at) +} + +func (s *Store) CompleteCandidateReportLeaseAt(jobID, attemptID, slot, generation, candidateSHA, result string, at time.Time) (Job, error) { if len(candidateSHA) != 40 { return Job{}, errors.New("invalid candidate SHA") } @@ -70,7 +74,7 @@ func (s *Store) CompleteCandidateLeaseAt(jobID, attemptID, slot, generation, can return Job{}, errors.New("invalid candidate SHA") } } - return s.terminalOwned(jobID, attemptID, "succeeded", "", candidateSHA, "", at.UTC(), slot, generation, true) + return s.terminalOwned(jobID, attemptID, "succeeded", result, candidateSHA, "", at.UTC(), slot, generation, true) } func (s *Store) FailLeaseAt(jobID, attemptID, slot, generation, code string, disposition FailureDisposition, at time.Time) (Job, error) { diff --git a/internal/store/store.go b/internal/store/store.go index 2eef9dc..8fe3e01 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -754,20 +754,28 @@ func (s *Store) CompleteCandidate(jobID, attemptID, candidateSHA string) (Job, e } func (s *Store) CompleteCandidateAt(jobID, attemptID, candidateSHA string, at time.Time) (Job, error) { + return s.CompleteCandidateReportAt(jobID, attemptID, candidateSHA, "", at) +} + +func (s *Store) CompleteCandidateReportAt(jobID, attemptID, candidateSHA, result string, at time.Time) (Job, error) { if len(candidateSHA) != 40 { return Job{}, errors.New("candidate SHA must be full length") } if _, err := hex.DecodeString(candidateSHA); err != nil { return Job{}, errors.New("candidate SHA is not hexadecimal") } - return s.terminal(jobID, attemptID, "succeeded", "", candidateSHA, "", at.UTC()) + return s.terminal(jobID, attemptID, "succeeded", result, candidateSHA, "", at.UTC()) } func (s *Store) CompleteCandidateDeliveryLeaseAt(jobID, attemptID, slot, generation string, delivery Delivery, at time.Time) (Job, error) { + return s.CompleteCandidateDeliveryReportLeaseAt(jobID, attemptID, slot, generation, delivery, "", at) +} + +func (s *Store) CompleteCandidateDeliveryReportLeaseAt(jobID, attemptID, slot, generation string, delivery Delivery, result string, at time.Time) (Job, error) { if delivery.JobID != jobID || delivery.AttemptID != attemptID || !lowerHex(jobID, 32) || !lowerHex(attemptID, 32) || !lowerHex(delivery.CandidateSHA, 40) || !lowerHex(delivery.ExpectedTreeSHA, 40) || !lowerHex(delivery.ParentSHA, 40) || delivery.CandidateRef != "refs/agent-forge/candidates/"+jobID+"/"+attemptID || delivery.RepositoryID == "" || delivery.RepositoryURL == "" || delivery.DefaultBranch == "" || delivery.Branch == "" || delivery.PRTitle == "" || delivery.MaxAttempts < 1 || delivery.MaxAttempts > 100 { return Job{}, errors.New("invalid delivery") } - return s.terminalOwnedState(jobID, attemptID, "succeeded", "", delivery.CandidateSHA, "", at.UTC(), slot, generation, true, &delivery) + return s.terminalOwnedState(jobID, attemptID, "succeeded", result, delivery.CandidateSHA, "", at.UTC(), slot, generation, true, &delivery) } func (s *Store) Fail(jobID, attemptID, code string) (Job, error) { @@ -842,6 +850,16 @@ func (s *Store) terminalOwned(jobID, attemptID, attemptStatus, result, candidate } func (s *Store) terminalOwnedState(jobID, attemptID, attemptStatus, result, candidateSHA, failure string, at time.Time, slot, generation string, enforceOwnership bool, delivery *Delivery) (Job, error) { + if candidateSHA != "" { + report, err := protocol.DecodeAgentReport(result) + if err != nil { + return Job{}, err + } + result, err = protocol.EncodeAgentReport(report) + if err != nil { + return Job{}, err + } + } tx, err := s.db.Begin() if err != nil { return Job{}, err @@ -954,7 +972,11 @@ func (s *Store) terminalOwnedState(jobID, attemptID, attemptStatus, result, cand } func (s *Store) Attempts(jobID string) ([]Attempt, error) { - rows, err := s.db.Query(`SELECT id,job_id,ordinal,worker_id,status,leased_at,deadline_at,completed_at,failure_disposition,failure_code,result,candidate_sha,worker_pool,slot,policy_version FROM attempts WHERE job_id=? ORDER BY ordinal`, jobID) + return s.attempts(jobID, -1) +} + +func (s *Store) attempts(jobID string, limit int) ([]Attempt, error) { + rows, err := s.db.Query(`SELECT id,job_id,ordinal,worker_id,status,leased_at,deadline_at,completed_at,failure_disposition,failure_code,result,candidate_sha,worker_pool,slot,policy_version FROM attempts WHERE job_id=? ORDER BY ordinal LIMIT ?`, jobID, limit) if err != nil { return nil, err } diff --git a/internal/worker/coding_test.go b/internal/worker/coding_test.go index d6b05f0..964fa36 100644 --- a/internal/worker/coding_test.go +++ b/internal/worker/coding_test.go @@ -870,3 +870,51 @@ func write(t *testing.T, path, content string) { t.Fatal(err) } } + +func TestCandidateCarriesCanonicalAgentReportOnlyAfterSuccessfulChecks(t *testing.T) { + for _, mode := range []string{"candidate", "failed-check", "no-changes"} { + t.Run(mode, func(t *testing.T) { + repo, base, plugin := codingFixture(t, "#!/bin/sh\nexit 0\n") + action := `pathlib.Path(request["workspace"], "answer.txt").write_text("candidate\n")` + if mode == "no-changes" { + action = "pass" + } + script := workspacePluginPython(action, "") + script = strings.Replace(script, `capabilities=["workspace_edit"]`, `capabilities=["workspace_edit","agent_report"]`, 1) + script = strings.Replace(script, `"type":"result"}`, `"type":"result","summary":"Update answer","changes":["Replace base answer with candidate"]}`, 1) + write(t, plugin, script) + check := "true" + if mode == "failed-check" { + check = "false" + } + outcome := executeCodingOutcome(context.Background(), plugin, []string{repo}, strings.Repeat("1", 32), strings.Repeat("2", 32), protocol.CodingTask{Repository: repo, BaseSHA: base, Instruction: "edit", Tests: [][]string{{check}}}) + defer outcome.cleanup() + if mode == "candidate" { + if outcome.err != nil || outcome.candidateSHA == "" || outcome.result != `{"summary":"Update answer","changes":["Replace base answer with candidate"]}` { + t.Fatalf("missing candidate report: result=%q err=%v", outcome.result, outcome.err) + } + } else if outcome.err == nil || outcome.result != "" || outcome.candidateSHA != "" { + t.Fatal("failed/no-candidate report escaped") + } + }) + } +} + +func TestNewWorkerOffersReportToLegacyPlugin(t *testing.T) { + script := `#!/usr/bin/env python3 +import json,sys +init=json.loads(sys.stdin.readline()) +assert "agent_report" in init["capabilities"] +print(json.dumps({"version":"v1","id":init["id"],"type":"initialized","capabilities":["workspace_edit","commit_subject"]},separators=(",",":")),flush=True) +json.loads(sys.stdin.readline()) +print(json.dumps({"version":"v1","id":init["id"],"type":"result","commit_subject":"fix: legacy"},separators=(",",":")),flush=True) +` + path := filepath.Join(t.TempDir(), "legacy.py") + if err := os.WriteFile(path, []byte(script), 0700); err != nil { + t.Fatal(err) + } + result, err := invokeLocalResult(context.Background(), []string{path}, pluginRequest{Workspace: t.TempDir(), Instruction: "fix"}, time.Second*5, 1<<20, nil) + if err != nil || result.CommitSubject == nil || result.Report != nil { + t.Fatalf("legacy result=%+v err=%v", result, err) + } +} diff --git a/internal/worker/policy.go b/internal/worker/policy.go index 6e7fea5..c8e9e7f 100644 --- a/internal/worker/policy.go +++ b/internal/worker/policy.go @@ -138,7 +138,7 @@ func executeConfiguredOutcome(ctx context.Context, config Config, message protoc return runScopedCheckLocal(ctx, worktree, environment, argv, settings.checkTimeout, settings.checkOutput) } outcome := executeCodingOutcomeSettings(ctx, settings, message.JobID, message.AttemptID, task, runCheck) - return leaseOutcome{candidateSHA: outcome.candidateSHA, err: outcome.err, evidence: outcome.evidence, cleanup: outcome.cleanup} + return leaseOutcome{result: outcome.result, candidateSHA: outcome.candidateSHA, err: outcome.err, evidence: outcome.evidence, cleanup: outcome.cleanup} } var environmentLookup = func(name string) (string, bool) { diff --git a/internal/worker/worker.go b/internal/worker/worker.go index f9086d4..c96a9f5 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -38,6 +38,7 @@ type pluginFailure struct{ reason string } func (e pluginFailure) Error() string { return "plugin failed" } type codingOutcome struct { + result string candidateSHA string err error evidence []protocol.AttemptEvidence @@ -303,7 +304,7 @@ func invokeLocalResult(parent context.Context, argv []string, request pluginRequ if request.Workspace != "" { operation = pluginprotocol.WorkspaceEdit protocolRequest = pluginprotocol.Request{Operation: operation, Workspace: request.Workspace, Instruction: request.Instruction, TimeoutMS: timeout.Milliseconds()} - capabilities = []pluginprotocol.Capability{pluginprotocol.Progress, pluginprotocol.Cancel, pluginprotocol.CommitSubject} + capabilities = []pluginprotocol.Capability{pluginprotocol.Progress, pluginprotocol.Cancel, pluginprotocol.CommitSubject, pluginprotocol.AgentReport} } result, err := pluginprotocol.Run(parent, argv, protocolRequest, pluginprotocol.Options{Timeout: timeout, OutputBytes: outputBytes, Capabilities: capabilities, Environment: environment}) if err != nil { @@ -505,7 +506,11 @@ func executeCodingOutcomeSettings(ctx context.Context, settings codingSettings, for i := range evidence { evidence[i].CandidateSHA = sha } - return codingOutcome{candidateSHA: sha, evidence: evidence, cleanup: cleanup} + report, err := protocol.EncodeAgentReport(pluginResult.Report) + if err != nil { + return codingOutcome{err: err, evidence: evidence, cleanup: cleanup} + } + return codingOutcome{result: report, candidateSHA: sha, evidence: evidence, cleanup: cleanup} } func boundedDurationMS(duration, limit time.Duration) int64 {