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
+
+
+
+Skip to workspace
+
+
+ WORKSPACE / OPERATIONS
Task board
+ Connect your workspace
Use your owner token to see projects, workers and work.
+
+
+
+
+
+
+
+
+
+
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"`, `