smol-symphony 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +41 -22
- package/DESIGN.md +494 -273
- package/README.md +109 -57
- package/SPEC.md +33 -24
- package/WORKFLOW.minimal.yaml +34 -0
- package/{WORKFLOW.template.md → WORKFLOW.template.yaml} +409 -256
- package/WORKFLOW.yaml +487 -0
- package/assets/skills/symphony-issues/SKILL.md +136 -0
- package/assets/symphony-mise.system.toml +68 -0
- package/dist/src/bin/symphony.js +30 -0
- package/dist/src/bin/symphony.js.map +1 -0
- package/dist/src/core/actions/context.js +109 -0
- package/dist/src/core/actions/context.js.map +1 -0
- package/dist/{actions/parsing.js → src/core/actions/parse.js} +33 -114
- package/dist/src/core/actions/parse.js.map +1 -0
- package/dist/src/core/actions/plan.js +197 -0
- package/dist/src/core/actions/plan.js.map +1 -0
- package/dist/src/core/actions/predicates.js +111 -0
- package/dist/src/core/actions/predicates.js.map +1 -0
- package/dist/src/core/actions/run-fold.js +248 -0
- package/dist/src/core/actions/run-fold.js.map +1 -0
- package/dist/src/core/actions/template.js +118 -0
- package/dist/src/core/actions/template.js.map +1 -0
- package/dist/src/core/cli/args.js +116 -0
- package/dist/src/core/cli/args.js.map +1 -0
- package/dist/src/core/coerce.js +75 -0
- package/dist/src/core/coerce.js.map +1 -0
- package/dist/src/core/credential/account-id.js +20 -0
- package/dist/src/core/credential/account-id.js.map +1 -0
- package/dist/src/core/credential/adapter-config.js +136 -0
- package/dist/src/core/credential/adapter-config.js.map +1 -0
- package/dist/src/core/credential/availability.js +98 -0
- package/dist/src/core/credential/availability.js.map +1 -0
- package/dist/src/core/credential/extract.js +228 -0
- package/dist/src/core/credential/extract.js.map +1 -0
- package/dist/src/core/credential/fake-creds.js +171 -0
- package/dist/src/core/credential/fake-creds.js.map +1 -0
- package/dist/src/core/credential/identity.js +125 -0
- package/dist/src/core/credential/identity.js.map +1 -0
- package/dist/src/core/credential/shape.js +230 -0
- package/dist/src/core/credential/shape.js.map +1 -0
- package/dist/src/core/credential/strings.js +15 -0
- package/dist/src/core/credential/strings.js.map +1 -0
- package/dist/src/core/doctor/checks.js +303 -0
- package/dist/src/core/doctor/checks.js.map +1 -0
- package/dist/src/core/git/result.js +107 -0
- package/dist/src/core/git/result.js.map +1 -0
- package/dist/src/core/http/decisions.js +225 -0
- package/dist/src/core/http/decisions.js.map +1 -0
- package/dist/{http.js → src/core/http/render.js} +472 -738
- package/dist/src/core/http/render.js.map +1 -0
- package/dist/{http-handlers.js → src/core/http/routes.js} +52 -87
- package/dist/src/core/http/routes.js.map +1 -0
- package/dist/src/core/http/views.js +181 -0
- package/dist/src/core/http/views.js.map +1 -0
- package/dist/src/core/image/managed-image.js +95 -0
- package/dist/src/core/image/managed-image.js.map +1 -0
- package/dist/src/core/issue/file.js +149 -0
- package/dist/src/core/issue/file.js.map +1 -0
- package/dist/src/core/issue/parse.js +210 -0
- package/dist/src/core/issue/parse.js.map +1 -0
- package/dist/src/core/mcp/dispatch.js +239 -0
- package/dist/src/core/mcp/dispatch.js.map +1 -0
- package/dist/src/core/mcp/post-move.js +92 -0
- package/dist/src/core/mcp/post-move.js.map +1 -0
- package/dist/src/core/mcp/protocol.js +293 -0
- package/dist/src/core/mcp/protocol.js.map +1 -0
- package/dist/src/core/mcp/url.js +162 -0
- package/dist/src/core/mcp/url.js.map +1 -0
- package/dist/src/core/path.js +63 -0
- package/dist/src/core/path.js.map +1 -0
- package/dist/src/core/reconcile/image-decide.js +48 -0
- package/dist/src/core/reconcile/image-decide.js.map +1 -0
- package/dist/src/core/reconcile/ledger.js +142 -0
- package/dist/src/core/reconcile/ledger.js.map +1 -0
- package/dist/src/core/reconcile/pr-classify.js +62 -0
- package/dist/src/core/reconcile/pr-classify.js.map +1 -0
- package/dist/{reconciler → src/core/reconcile}/pr-decide.js +25 -12
- package/dist/src/core/reconcile/pr-decide.js.map +1 -0
- package/dist/src/core/reconcile/pr-loop.js +161 -0
- package/dist/src/core/reconcile/pr-loop.js.map +1 -0
- package/dist/src/core/reconcile/pr-notes.js +35 -0
- package/dist/src/core/reconcile/pr-notes.js.map +1 -0
- package/dist/src/core/reconcile/vm-decide.js +70 -0
- package/dist/src/core/reconcile/vm-decide.js.map +1 -0
- package/dist/src/core/reconcile/vm-reap.js +207 -0
- package/dist/src/core/reconcile/vm-reap.js.map +1 -0
- package/dist/src/core/reconcile/workspace-decide.js +162 -0
- package/dist/src/core/reconcile/workspace-decide.js.map +1 -0
- package/dist/src/core/runlog/summary.js +231 -0
- package/dist/src/core/runlog/summary.js.map +1 -0
- package/dist/src/core/runner/dispatch-config.js +95 -0
- package/dist/src/core/runner/dispatch-config.js.map +1 -0
- package/dist/src/core/runner/injection.js +61 -0
- package/dist/src/core/runner/injection.js.map +1 -0
- package/dist/src/core/runner/mise.js +210 -0
- package/dist/src/core/runner/mise.js.map +1 -0
- package/dist/src/core/runner/prompt.js +720 -0
- package/dist/src/core/runner/prompt.js.map +1 -0
- package/dist/src/core/runner/turn.js +242 -0
- package/dist/src/core/runner/turn.js.map +1 -0
- package/dist/src/core/runner/vm-plan.js +390 -0
- package/dist/src/core/runner/vm-plan.js.map +1 -0
- package/dist/src/core/schedule/admission.js +123 -0
- package/dist/src/core/schedule/admission.js.map +1 -0
- package/dist/src/core/schedule/circuit-breaker.js +111 -0
- package/dist/src/core/schedule/circuit-breaker.js.map +1 -0
- package/dist/src/core/schedule/eligibility.js +83 -0
- package/dist/src/core/schedule/eligibility.js.map +1 -0
- package/dist/src/core/schedule/reconcile-issue.js +82 -0
- package/dist/src/core/schedule/reconcile-issue.js.map +1 -0
- package/dist/src/core/schedule/retry.js +96 -0
- package/dist/src/core/schedule/retry.js.map +1 -0
- package/dist/src/core/schedule/sleep-cycle.js +133 -0
- package/dist/src/core/schedule/sleep-cycle.js.map +1 -0
- package/dist/src/core/schedule/slots.js +124 -0
- package/dist/src/core/schedule/slots.js.map +1 -0
- package/dist/src/core/schedule/tick.js +553 -0
- package/dist/src/core/schedule/tick.js.map +1 -0
- package/dist/src/core/schedule/token-fold.js +181 -0
- package/dist/src/core/schedule/token-fold.js.map +1 -0
- package/dist/src/core/state-resolve.js +86 -0
- package/dist/src/core/state-resolve.js.map +1 -0
- package/dist/src/core/vm-guards.js +278 -0
- package/dist/src/core/vm-guards.js.map +1 -0
- package/dist/src/core/workflow/derive.js +107 -0
- package/dist/src/core/workflow/derive.js.map +1 -0
- package/dist/src/core/workflow/parse.js +687 -0
- package/dist/src/core/workflow/parse.js.map +1 -0
- package/dist/src/core/workflow/prompt-probe.js +78 -0
- package/dist/src/core/workflow/prompt-probe.js.map +1 -0
- package/dist/src/core/workflow/validate.js +189 -0
- package/dist/src/core/workflow/validate.js.map +1 -0
- package/dist/src/core/workspace-key.js +19 -0
- package/dist/src/core/workspace-key.js.map +1 -0
- package/dist/src/shell/actions-runner.js +356 -0
- package/dist/src/shell/actions-runner.js.map +1 -0
- package/dist/src/shell/adapter/adapter-registry.js +45 -0
- package/dist/src/shell/adapter/adapter-registry.js.map +1 -0
- package/dist/src/shell/adapter/clock-random.js +96 -0
- package/dist/src/shell/adapter/clock-random.js.map +1 -0
- package/dist/src/shell/adapter/gondolin-dispatch-helpers.js +158 -0
- package/dist/src/shell/adapter/gondolin-dispatch-helpers.js.map +1 -0
- package/dist/src/shell/adapter/gondolin-dispatch.js +385 -0
- package/dist/src/shell/adapter/gondolin-dispatch.js.map +1 -0
- package/dist/src/shell/adapter/gondolin-image-converter.js +233 -0
- package/dist/src/shell/adapter/gondolin-image-converter.js.map +1 -0
- package/dist/src/shell/adapter/gondolin-image-fetch.js +180 -0
- package/dist/src/shell/adapter/gondolin-image-fetch.js.map +1 -0
- package/dist/src/shell/adapter/launcher-asset.js +57 -0
- package/dist/src/shell/adapter/launcher-asset.js.map +1 -0
- package/dist/src/shell/adapter/mise-config-asset.js +65 -0
- package/dist/src/shell/adapter/mise-config-asset.js.map +1 -0
- package/dist/src/shell/adapter/workflow-loader.js +304 -0
- package/dist/src/shell/adapter/workflow-loader.js.map +1 -0
- package/dist/src/shell/cli/doctor.js +268 -0
- package/dist/src/shell/cli/doctor.js.map +1 -0
- package/dist/src/shell/effect-interpreter-families.js +314 -0
- package/dist/src/shell/effect-interpreter-families.js.map +1 -0
- package/dist/src/shell/effect-interpreter.js +29 -0
- package/dist/src/shell/effect-interpreter.js.map +1 -0
- package/dist/src/shell/interp/acp-frame.js +137 -0
- package/dist/src/shell/interp/acp-frame.js.map +1 -0
- package/dist/src/shell/interp/acp-ws-conn.js +320 -0
- package/dist/src/shell/interp/acp-ws-conn.js.map +1 -0
- package/dist/src/shell/interp/acp-ws-frames.js +159 -0
- package/dist/src/shell/interp/acp-ws-frames.js.map +1 -0
- package/dist/src/shell/interp/acp-ws.js +197 -0
- package/dist/src/shell/interp/acp-ws.js.map +1 -0
- package/dist/src/shell/interp/acp.js +319 -0
- package/dist/src/shell/interp/acp.js.map +1 -0
- package/dist/src/shell/interp/credential-defaults.js +128 -0
- package/dist/src/shell/interp/credential-defaults.js.map +1 -0
- package/dist/src/shell/interp/credential-hooks.js +149 -0
- package/dist/src/shell/interp/credential-hooks.js.map +1 -0
- package/dist/src/shell/interp/credential-registry.js +226 -0
- package/dist/src/shell/interp/credential-registry.js.map +1 -0
- package/dist/src/shell/interp/credential.js +103 -0
- package/dist/src/shell/interp/credential.js.map +1 -0
- package/dist/src/shell/interp/gh.js +163 -0
- package/dist/src/shell/interp/gh.js.map +1 -0
- package/dist/src/shell/interp/git.js +28 -0
- package/dist/src/shell/interp/git.js.map +1 -0
- package/dist/src/shell/interp/log.js +213 -0
- package/dist/src/shell/interp/log.js.map +1 -0
- package/dist/src/shell/interp/process.js +178 -0
- package/dist/src/shell/interp/process.js.map +1 -0
- package/dist/src/shell/interp/runlog.js +193 -0
- package/dist/src/shell/interp/runlog.js.map +1 -0
- package/dist/src/shell/interp/timer.js +64 -0
- package/dist/src/shell/interp/timer.js.map +1 -0
- package/dist/src/shell/interp/tracker-disk.js +99 -0
- package/dist/src/shell/interp/tracker-disk.js.map +1 -0
- package/dist/src/shell/interp/tracker-parse.js +71 -0
- package/dist/src/shell/interp/tracker-parse.js.map +1 -0
- package/dist/src/shell/interp/tracker-scan.js +238 -0
- package/dist/src/shell/interp/tracker-scan.js.map +1 -0
- package/dist/src/shell/interp/tracker-write.js +91 -0
- package/dist/src/shell/interp/tracker-write.js.map +1 -0
- package/dist/src/shell/interp/tracker.js +41 -0
- package/dist/src/shell/interp/tracker.js.map +1 -0
- package/dist/src/shell/interp/tty.js +48 -0
- package/dist/src/shell/interp/tty.js.map +1 -0
- package/dist/src/shell/interp/vm.js +199 -0
- package/dist/src/shell/interp/vm.js.map +1 -0
- package/dist/src/shell/interp/workspace.js +310 -0
- package/dist/src/shell/interp/workspace.js.map +1 -0
- package/dist/src/shell/main-acp.js +78 -0
- package/dist/src/shell/main-acp.js.map +1 -0
- package/dist/src/shell/main-adapters.js +222 -0
- package/dist/src/shell/main-adapters.js.map +1 -0
- package/dist/src/shell/main-credential.js +122 -0
- package/dist/src/shell/main-credential.js.map +1 -0
- package/dist/src/shell/main-doctor.js +22 -0
- package/dist/src/shell/main-doctor.js.map +1 -0
- package/dist/src/shell/main-entry.js +46 -0
- package/dist/src/shell/main-entry.js.map +1 -0
- package/dist/src/shell/main-http-csrf.js +45 -0
- package/dist/src/shell/main-http-csrf.js.map +1 -0
- package/dist/src/shell/main-http-handler.js +389 -0
- package/dist/src/shell/main-http-handler.js.map +1 -0
- package/dist/src/shell/main-http-mcp.js +122 -0
- package/dist/src/shell/main-http-mcp.js.map +1 -0
- package/dist/src/shell/main-http-views.js +253 -0
- package/dist/src/shell/main-http-views.js.map +1 -0
- package/dist/src/shell/main-http.js +76 -0
- package/dist/src/shell/main-http.js.map +1 -0
- package/dist/src/shell/main-loops.js +130 -0
- package/dist/src/shell/main-loops.js.map +1 -0
- package/dist/src/shell/main-mcp.js +129 -0
- package/dist/src/shell/main-mcp.js.map +1 -0
- package/dist/src/shell/main-orchestrator.js +120 -0
- package/dist/src/shell/main-orchestrator.js.map +1 -0
- package/dist/src/shell/main-preflight.js +43 -0
- package/dist/src/shell/main-preflight.js.map +1 -0
- package/dist/src/shell/main-reconcilers-helpers.js +244 -0
- package/dist/src/shell/main-reconcilers-helpers.js.map +1 -0
- package/dist/src/shell/main-reconcilers-pr.js +148 -0
- package/dist/src/shell/main-reconcilers-pr.js.map +1 -0
- package/dist/src/shell/main-reconcilers.js +225 -0
- package/dist/src/shell/main-reconcilers.js.map +1 -0
- package/dist/src/shell/main-runner.js +355 -0
- package/dist/src/shell/main-runner.js.map +1 -0
- package/dist/src/shell/main-scaffold.js +116 -0
- package/dist/src/shell/main-scaffold.js.map +1 -0
- package/dist/src/shell/main-shutdown.js +115 -0
- package/dist/src/shell/main-shutdown.js.map +1 -0
- package/dist/src/shell/main-startup.js +48 -0
- package/dist/src/shell/main-startup.js.map +1 -0
- package/dist/src/shell/main-substrates.js +43 -0
- package/dist/src/shell/main-substrates.js.map +1 -0
- package/dist/src/shell/main.js +385 -0
- package/dist/src/shell/main.js.map +1 -0
- package/dist/src/shell/orchestrator-feedback.js +69 -0
- package/dist/src/shell/orchestrator-feedback.js.map +1 -0
- package/dist/src/shell/orchestrator-image.js +167 -0
- package/dist/src/shell/orchestrator-image.js.map +1 -0
- package/dist/src/shell/orchestrator-loop.js +468 -0
- package/dist/src/shell/orchestrator-loop.js.map +1 -0
- package/dist/src/shell/orchestrator-reconcile.js +36 -0
- package/dist/src/shell/orchestrator-reconcile.js.map +1 -0
- package/dist/src/shell/reconciler-loop.js +228 -0
- package/dist/src/shell/reconciler-loop.js.map +1 -0
- package/dist/src/shell/runner-loop-turn.js +301 -0
- package/dist/src/shell/runner-loop-turn.js.map +1 -0
- package/dist/src/shell/runner-loop.js +338 -0
- package/dist/src/shell/runner-loop.js.map +1 -0
- package/dist/src/shell/server/http.js +208 -0
- package/dist/src/shell/server/http.js.map +1 -0
- package/dist/src/shell/server/mcp-runtime-effects.js +237 -0
- package/dist/src/shell/server/mcp-runtime-effects.js.map +1 -0
- package/dist/src/shell/server/mcp-runtime.js +99 -0
- package/dist/src/shell/server/mcp-runtime.js.map +1 -0
- package/dist/src/shell/workspace-key.js +14 -0
- package/dist/src/shell/workspace-key.js.map +1 -0
- package/dist/src/types/acp.js +8 -0
- package/dist/src/types/acp.js.map +1 -0
- package/dist/src/types/actions/plan.js +6 -0
- package/dist/src/types/actions/plan.js.map +1 -0
- package/dist/src/types/actions/predicates.js +6 -0
- package/dist/src/types/actions/predicates.js.map +1 -0
- package/dist/src/types/actions/run-fold.js +8 -0
- package/dist/src/types/actions/run-fold.js.map +1 -0
- package/dist/src/types/actions.js +7 -0
- package/dist/src/types/actions.js.map +1 -0
- package/dist/src/types/adapter/clock-random.js +4 -0
- package/dist/src/types/adapter/clock-random.js.map +1 -0
- package/dist/src/types/adapter/gondolin-image-converter.js +5 -0
- package/dist/src/types/adapter/gondolin-image-converter.js.map +1 -0
- package/dist/src/types/adapter/gondolin-image-fetch.js +5 -0
- package/dist/src/types/adapter/gondolin-image-fetch.js.map +1 -0
- package/dist/src/types/adapter/workflow-loader.js +4 -0
- package/dist/src/types/adapter/workflow-loader.js.map +1 -0
- package/dist/src/types/cli/args.js +8 -0
- package/dist/src/types/cli/args.js.map +1 -0
- package/dist/src/types/config.js +8 -0
- package/dist/src/types/config.js.map +1 -0
- package/dist/src/types/credential-interp.js +6 -0
- package/dist/src/types/credential-interp.js.map +1 -0
- package/dist/src/types/credentials.js +10 -0
- package/dist/src/types/credentials.js.map +1 -0
- package/dist/src/types/doctor.js +7 -0
- package/dist/src/types/doctor.js.map +1 -0
- package/dist/src/types/domain.js +7 -0
- package/dist/src/types/domain.js.map +1 -0
- package/dist/src/types/effect.js +15 -0
- package/dist/src/types/effect.js.map +1 -0
- package/dist/src/types/errors.js +39 -0
- package/dist/src/types/errors.js.map +1 -0
- package/dist/src/types/http/decisions.js +6 -0
- package/dist/src/types/http/decisions.js.map +1 -0
- package/dist/src/types/http/render.js +10 -0
- package/dist/src/types/http/render.js.map +1 -0
- package/dist/src/types/http/views.js +6 -0
- package/dist/src/types/http/views.js.map +1 -0
- package/dist/src/types/http.js +9 -0
- package/dist/src/types/http.js.map +1 -0
- package/dist/src/types/image/managed-image.js +7 -0
- package/dist/src/types/image/managed-image.js.map +1 -0
- package/dist/src/types/interp/effect-interpreter.js +8 -0
- package/dist/src/types/interp/effect-interpreter.js.map +1 -0
- package/dist/src/types/interp/tracker.js +7 -0
- package/dist/src/types/interp/tracker.js.map +1 -0
- package/dist/src/types/issue/file.js +6 -0
- package/dist/src/types/issue/file.js.map +1 -0
- package/dist/src/types/issue/parse.js +8 -0
- package/dist/src/types/issue/parse.js.map +1 -0
- package/dist/src/types/main-acp.js +13 -0
- package/dist/src/types/main-acp.js.map +1 -0
- package/dist/src/types/main-adapters.js +5 -0
- package/dist/src/types/main-adapters.js.map +1 -0
- package/dist/src/types/main-credential.js +21 -0
- package/dist/src/types/main-credential.js.map +1 -0
- package/dist/src/types/main-doctor.js +6 -0
- package/dist/src/types/main-doctor.js.map +1 -0
- package/dist/src/types/main-http-handler.js +12 -0
- package/dist/src/types/main-http-handler.js.map +1 -0
- package/dist/src/types/main-http.js +5 -0
- package/dist/src/types/main-http.js.map +1 -0
- package/dist/src/types/main-loops.js +5 -0
- package/dist/src/types/main-loops.js.map +1 -0
- package/dist/src/types/main-mcp.js +12 -0
- package/dist/src/types/main-mcp.js.map +1 -0
- package/dist/src/types/main-orchestrator.js +5 -0
- package/dist/src/types/main-orchestrator.js.map +1 -0
- package/dist/src/types/main-reconcilers.js +11 -0
- package/dist/src/types/main-reconcilers.js.map +1 -0
- package/dist/src/types/main-runner.js +13 -0
- package/dist/src/types/main-runner.js.map +1 -0
- package/dist/src/types/main-startup.js +5 -0
- package/dist/src/types/main-startup.js.map +1 -0
- package/dist/src/types/main-substrates.js +5 -0
- package/dist/src/types/main-substrates.js.map +1 -0
- package/dist/src/types/mcp/dispatch.js +4 -0
- package/dist/src/types/mcp/dispatch.js.map +1 -0
- package/dist/src/types/mcp/post-move.js +7 -0
- package/dist/src/types/mcp/post-move.js.map +1 -0
- package/dist/src/types/mcp.js +9 -0
- package/dist/src/types/mcp.js.map +1 -0
- package/dist/src/types/ports.js +12 -0
- package/dist/src/types/ports.js.map +1 -0
- package/dist/src/types/reconcile/image-decide.js +5 -0
- package/dist/src/types/reconcile/image-decide.js.map +1 -0
- package/dist/src/types/reconcile/ledger.js +7 -0
- package/dist/src/types/reconcile/ledger.js.map +1 -0
- package/dist/src/types/reconcile/pr-loop.js +8 -0
- package/dist/src/types/reconcile/pr-loop.js.map +1 -0
- package/dist/src/types/reconcile/vm-reap.js +8 -0
- package/dist/src/types/reconcile/vm-reap.js.map +1 -0
- package/dist/src/types/reconcile/workspace-decide.js +7 -0
- package/dist/src/types/reconcile/workspace-decide.js.map +1 -0
- package/dist/src/types/reconcile.js +9 -0
- package/dist/src/types/reconcile.js.map +1 -0
- package/dist/src/types/runlog.js +7 -0
- package/dist/src/types/runlog.js.map +1 -0
- package/dist/src/types/runner/actions-runner.js +12 -0
- package/dist/src/types/runner/actions-runner.js.map +1 -0
- package/dist/src/types/runner/gondolin-dispatch.js +5 -0
- package/dist/src/types/runner/gondolin-dispatch.js.map +1 -0
- package/dist/src/types/runner/injection.js +6 -0
- package/dist/src/types/runner/injection.js.map +1 -0
- package/dist/src/types/runner/runner-loop.js +5 -0
- package/dist/src/types/runner/runner-loop.js.map +1 -0
- package/dist/src/types/runner/turn.js +4 -0
- package/dist/src/types/runner/turn.js.map +1 -0
- package/dist/src/types/runner/vm-plan.js +4 -0
- package/dist/src/types/runner/vm-plan.js.map +1 -0
- package/dist/src/types/runtime.js +9 -0
- package/dist/src/types/runtime.js.map +1 -0
- package/dist/src/types/schedule/admission.js +7 -0
- package/dist/src/types/schedule/admission.js.map +1 -0
- package/dist/src/types/schedule/circuit-breaker.js +2 -0
- package/dist/src/types/schedule/circuit-breaker.js.map +1 -0
- package/dist/src/types/schedule/eligibility.js +9 -0
- package/dist/src/types/schedule/eligibility.js.map +1 -0
- package/dist/src/types/schedule/orchestrator-loop.js +10 -0
- package/dist/src/types/schedule/orchestrator-loop.js.map +1 -0
- package/dist/src/types/schedule/sleep-cycle.js +4 -0
- package/dist/src/types/schedule/sleep-cycle.js.map +1 -0
- package/dist/src/types/schedule/slots.js +8 -0
- package/dist/src/types/schedule/slots.js.map +1 -0
- package/dist/src/types/schedule/tick.js +9 -0
- package/dist/src/types/schedule/tick.js.map +1 -0
- package/dist/src/types/server/mcp-runtime.js +8 -0
- package/dist/src/types/server/mcp-runtime.js.map +1 -0
- package/dist/src/types/workflow/parse.js +4 -0
- package/dist/src/types/workflow/parse.js.map +1 -0
- package/dist/tests/core/account-id.test.js +35 -0
- package/dist/tests/core/account-id.test.js.map +1 -0
- package/dist/tests/core/actions-parse.test.js +176 -0
- package/dist/tests/core/actions-parse.test.js.map +1 -0
- package/dist/tests/core/adapter-config.test.js +133 -0
- package/dist/tests/core/adapter-config.test.js.map +1 -0
- package/dist/tests/core/admission.test.js +215 -0
- package/dist/tests/core/admission.test.js.map +1 -0
- package/dist/tests/core/args.test.js +132 -0
- package/dist/tests/core/args.test.js.map +1 -0
- package/dist/tests/core/availability.test.js +62 -0
- package/dist/tests/core/availability.test.js.map +1 -0
- package/dist/tests/core/checks.test.js +395 -0
- package/dist/tests/core/checks.test.js.map +1 -0
- package/dist/tests/core/circuit-breaker.test.js +172 -0
- package/dist/tests/core/circuit-breaker.test.js.map +1 -0
- package/dist/tests/core/coerce.test.js +87 -0
- package/dist/tests/core/coerce.test.js.map +1 -0
- package/dist/tests/core/context.test.js +228 -0
- package/dist/tests/core/context.test.js.map +1 -0
- package/dist/tests/core/decisions.test.js +310 -0
- package/dist/tests/core/decisions.test.js.map +1 -0
- package/dist/tests/core/derive.test.js +205 -0
- package/dist/tests/core/derive.test.js.map +1 -0
- package/dist/tests/core/dispatch-config.test.js +164 -0
- package/dist/tests/core/dispatch-config.test.js.map +1 -0
- package/dist/tests/core/dispatch.test.js +302 -0
- package/dist/tests/core/dispatch.test.js.map +1 -0
- package/dist/tests/core/eligibility.test.js +163 -0
- package/dist/tests/core/eligibility.test.js.map +1 -0
- package/dist/tests/core/extract.test.js +139 -0
- package/dist/tests/core/extract.test.js.map +1 -0
- package/dist/tests/core/fake-creds.test.js +134 -0
- package/dist/tests/core/fake-creds.test.js.map +1 -0
- package/dist/tests/core/file.test.js +197 -0
- package/dist/tests/core/file.test.js.map +1 -0
- package/dist/tests/core/git-result.test.js +113 -0
- package/dist/tests/core/git-result.test.js.map +1 -0
- package/dist/tests/core/identity.test.js +180 -0
- package/dist/tests/core/identity.test.js.map +1 -0
- package/dist/tests/core/image-decide.test.js +59 -0
- package/dist/tests/core/image-decide.test.js.map +1 -0
- package/dist/tests/core/injection.test.js +163 -0
- package/dist/tests/core/injection.test.js.map +1 -0
- package/dist/tests/core/ledger.test.js +218 -0
- package/dist/tests/core/ledger.test.js.map +1 -0
- package/dist/tests/core/managed-image.test.js +68 -0
- package/dist/tests/core/managed-image.test.js.map +1 -0
- package/dist/tests/core/mise.test.js +138 -0
- package/dist/tests/core/mise.test.js.map +1 -0
- package/dist/tests/core/parse.test.js +174 -0
- package/dist/tests/core/parse.test.js.map +1 -0
- package/dist/tests/core/path.test.js +50 -0
- package/dist/tests/core/path.test.js.map +1 -0
- package/dist/tests/core/plan.test.js +218 -0
- package/dist/tests/core/plan.test.js.map +1 -0
- package/dist/tests/core/post-move.test.js +162 -0
- package/dist/tests/core/post-move.test.js.map +1 -0
- package/dist/tests/core/pr-classify.test.js +117 -0
- package/dist/tests/core/pr-classify.test.js.map +1 -0
- package/dist/tests/core/pr-decide.test.js +298 -0
- package/dist/tests/core/pr-decide.test.js.map +1 -0
- package/dist/tests/core/pr-loop.test.js +301 -0
- package/dist/tests/core/pr-loop.test.js.map +1 -0
- package/dist/tests/core/pr-notes.test.js +165 -0
- package/dist/tests/core/pr-notes.test.js.map +1 -0
- package/dist/tests/core/predicates.test.js +154 -0
- package/dist/tests/core/predicates.test.js.map +1 -0
- package/dist/tests/core/prompt.test.js +189 -0
- package/dist/tests/core/prompt.test.js.map +1 -0
- package/dist/tests/core/protocol.test.js +195 -0
- package/dist/tests/core/protocol.test.js.map +1 -0
- package/dist/tests/core/reconcile-issue.test.js +116 -0
- package/dist/tests/core/reconcile-issue.test.js.map +1 -0
- package/dist/tests/core/render.test.js +549 -0
- package/dist/tests/core/render.test.js.map +1 -0
- package/dist/tests/core/retry.test.js +186 -0
- package/dist/tests/core/retry.test.js.map +1 -0
- package/dist/tests/core/routes.test.js +247 -0
- package/dist/tests/core/routes.test.js.map +1 -0
- package/dist/tests/core/run-fold.test.js +299 -0
- package/dist/tests/core/run-fold.test.js.map +1 -0
- package/dist/tests/core/shape.test.js +185 -0
- package/dist/tests/core/shape.test.js.map +1 -0
- package/dist/tests/core/sleep-cycle.test.js +150 -0
- package/dist/tests/core/sleep-cycle.test.js.map +1 -0
- package/dist/tests/core/slots.test.js +201 -0
- package/dist/tests/core/slots.test.js.map +1 -0
- package/dist/tests/core/state-resolve.test.js +80 -0
- package/dist/tests/core/state-resolve.test.js.map +1 -0
- package/dist/tests/core/summary.test.js +200 -0
- package/dist/tests/core/summary.test.js.map +1 -0
- package/dist/tests/core/template.test.js +116 -0
- package/dist/tests/core/template.test.js.map +1 -0
- package/dist/tests/core/tick.test.js +558 -0
- package/dist/tests/core/tick.test.js.map +1 -0
- package/dist/tests/core/token-fold.test.js +176 -0
- package/dist/tests/core/token-fold.test.js.map +1 -0
- package/dist/tests/core/turn.test.js +388 -0
- package/dist/tests/core/turn.test.js.map +1 -0
- package/dist/tests/core/url.test.js +118 -0
- package/dist/tests/core/url.test.js.map +1 -0
- package/dist/tests/core/validate.test.js +247 -0
- package/dist/tests/core/validate.test.js.map +1 -0
- package/dist/tests/core/views.test.js +252 -0
- package/dist/tests/core/views.test.js.map +1 -0
- package/dist/tests/core/vm-decide.test.js +110 -0
- package/dist/tests/core/vm-decide.test.js.map +1 -0
- package/dist/tests/core/vm-guards.test.js +153 -0
- package/dist/tests/core/vm-guards.test.js.map +1 -0
- package/dist/tests/core/vm-plan.test.js +332 -0
- package/dist/tests/core/vm-plan.test.js.map +1 -0
- package/dist/tests/core/vm-reap.test.js +196 -0
- package/dist/tests/core/vm-reap.test.js.map +1 -0
- package/dist/tests/core/workflow-parse.test.js +493 -0
- package/dist/tests/core/workflow-parse.test.js.map +1 -0
- package/dist/tests/core/workspace-decide.test.js +236 -0
- package/dist/tests/core/workspace-decide.test.js.map +1 -0
- package/dist/tests/helpers/fixtures.js +167 -0
- package/dist/tests/helpers/fixtures.js.map +1 -0
- package/dist/tests/shell/acp-substrate.test.js +101 -0
- package/dist/tests/shell/acp-substrate.test.js.map +1 -0
- package/dist/tests/shell/actions-runner-push.test.js +203 -0
- package/dist/tests/shell/actions-runner-push.test.js.map +1 -0
- package/dist/tests/shell/credential-hooks.test.js +36 -0
- package/dist/tests/shell/credential-hooks.test.js.map +1 -0
- package/dist/tests/shell/credential-registry.test.js +165 -0
- package/dist/tests/shell/credential-registry.test.js.map +1 -0
- package/dist/tests/shell/credential-substrate.test.js +179 -0
- package/dist/tests/shell/credential-substrate.test.js.map +1 -0
- package/dist/tests/shell/dockerfile-mise-pin.test.js +51 -0
- package/dist/tests/shell/dockerfile-mise-pin.test.js.map +1 -0
- package/dist/tests/shell/doctor.test.js +101 -0
- package/dist/tests/shell/doctor.test.js.map +1 -0
- package/dist/tests/shell/effect-vm-create.test.js +52 -0
- package/dist/tests/shell/effect-vm-create.test.js.map +1 -0
- package/dist/tests/shell/gh-port.test.js +63 -0
- package/dist/tests/shell/gh-port.test.js.map +1 -0
- package/dist/tests/shell/gondolin-dispatch-guard.test.js +144 -0
- package/dist/tests/shell/gondolin-dispatch-guard.test.js.map +1 -0
- package/dist/tests/shell/gondolin-dispatch-shquote.test.js +168 -0
- package/dist/tests/shell/gondolin-dispatch-shquote.test.js.map +1 -0
- package/dist/tests/shell/gondolin-image-converter.test.js +208 -0
- package/dist/tests/shell/gondolin-image-converter.test.js.map +1 -0
- package/dist/tests/shell/gondolin-image-fetch.test.js +93 -0
- package/dist/tests/shell/gondolin-image-fetch.test.js.map +1 -0
- package/dist/tests/shell/http-handler.test.js +608 -0
- package/dist/tests/shell/http-handler.test.js.map +1 -0
- package/dist/tests/shell/http-server.test.js +53 -0
- package/dist/tests/shell/http-server.test.js.map +1 -0
- package/dist/tests/shell/mcp-runtime.test.js +366 -0
- package/dist/tests/shell/mcp-runtime.test.js.map +1 -0
- package/dist/tests/shell/mise-config-asset.test.js +87 -0
- package/dist/tests/shell/mise-config-asset.test.js.map +1 -0
- package/dist/tests/shell/orchestrator-loop.test.js +583 -0
- package/dist/tests/shell/orchestrator-loop.test.js.map +1 -0
- package/dist/tests/shell/reconciler-passes.test.js +314 -0
- package/dist/tests/shell/reconciler-passes.test.js.map +1 -0
- package/dist/tests/shell/runner-loop-turn.test.js +97 -0
- package/dist/tests/shell/runner-loop-turn.test.js.map +1 -0
- package/dist/tests/shell/runner-slice.test.js +536 -0
- package/dist/tests/shell/runner-slice.test.js.map +1 -0
- package/dist/tests/shell/scaffold.test.js +65 -0
- package/dist/tests/shell/scaffold.test.js.map +1 -0
- package/dist/tests/shell/tick-config.test.js +83 -0
- package/dist/tests/shell/tick-config.test.js.map +1 -0
- package/dist/tests/shell/tracker-parse-dates.test.js +44 -0
- package/dist/tests/shell/tracker-parse-dates.test.js.map +1 -0
- package/dist/tests/shell/tracker-write-issue.test.js +154 -0
- package/dist/tests/shell/tracker-write-issue.test.js.map +1 -0
- package/dist/tests/shell/workflow-prompt-split.test.js +208 -0
- package/dist/tests/shell/workflow-prompt-split.test.js.map +1 -0
- package/dist/tests/shell/workspace-live-config.test.js +140 -0
- package/dist/tests/shell/workspace-live-config.test.js.map +1 -0
- package/package.json +21 -9
- package/patches/@earendil-works+gondolin+0.12.0.patch +173 -0
- package/prompts/Reflect.md +91 -0
- package/prompts/Review.md +97 -0
- package/prompts/Todo.md +96 -0
- package/prompts/_footer.md +41 -0
- package/prompts/_preamble.md +42 -0
- package/prompts-minimal/Todo.md +26 -0
- package/scripts/postinstall.mjs +63 -0
- package/scripts/vm-agent.mjs +312 -90
- package/WORKFLOW.md +0 -744
- package/dist/acp-bridge.js +0 -324
- package/dist/acp-bridge.js.map +0 -1
- package/dist/actions/cache.js +0 -191
- package/dist/actions/cache.js.map +0 -1
- package/dist/actions/effects.js +0 -41
- package/dist/actions/effects.js.map +0 -1
- package/dist/actions/executor.js +0 -570
- package/dist/actions/executor.js.map +0 -1
- package/dist/actions/index.js +0 -13
- package/dist/actions/index.js.map +0 -1
- package/dist/actions/parsing.js.map +0 -1
- package/dist/actions/predicate-env.js +0 -27
- package/dist/actions/predicate-env.js.map +0 -1
- package/dist/actions/predicates.js +0 -49
- package/dist/actions/predicates.js.map +0 -1
- package/dist/actions/templating.js +0 -66
- package/dist/actions/templating.js.map +0 -1
- package/dist/actions/types.js +0 -15
- package/dist/actions/types.js.map +0 -1
- package/dist/agent/acp.js +0 -473
- package/dist/agent/acp.js.map +0 -1
- package/dist/agent/adapter-names.js +0 -159
- package/dist/agent/adapter-names.js.map +0 -1
- package/dist/agent/adapters.js +0 -511
- package/dist/agent/adapters.js.map +0 -1
- package/dist/agent/credential-extractors.js +0 -342
- package/dist/agent/credential-extractors.js.map +0 -1
- package/dist/agent/credential-secrets.js +0 -628
- package/dist/agent/credential-secrets.js.map +0 -1
- package/dist/agent/credential-ticker.js +0 -57
- package/dist/agent/credential-ticker.js.map +0 -1
- package/dist/agent/gondolin-creds-staging.js +0 -356
- package/dist/agent/gondolin-creds-staging.js.map +0 -1
- package/dist/agent/gondolin-dispatch.js +0 -375
- package/dist/agent/gondolin-dispatch.js.map +0 -1
- package/dist/agent/gondolin.js +0 -124
- package/dist/agent/gondolin.js.map +0 -1
- package/dist/agent/runner-decisions.js +0 -134
- package/dist/agent/runner-decisions.js.map +0 -1
- package/dist/agent/runner.js +0 -1456
- package/dist/agent/runner.js.map +0 -1
- package/dist/agent/tool-call-summary.js +0 -102
- package/dist/agent/tool-call-summary.js.map +0 -1
- package/dist/agent/vm-acp-mapping.js +0 -73
- package/dist/agent/vm-acp-mapping.js.map +0 -1
- package/dist/agent/vm-guards.js +0 -262
- package/dist/agent/vm-guards.js.map +0 -1
- package/dist/agent/vm-port.js +0 -22
- package/dist/agent/vm-port.js.map +0 -1
- package/dist/agent/vm-process-registry.js +0 -79
- package/dist/agent/vm-process-registry.js.map +0 -1
- package/dist/bin/cli-args.js +0 -105
- package/dist/bin/cli-args.js.map +0 -1
- package/dist/bin/symphony.js +0 -794
- package/dist/bin/symphony.js.map +0 -1
- package/dist/errors.js +0 -15
- package/dist/errors.js.map +0 -1
- package/dist/http-disk.js +0 -135
- package/dist/http-disk.js.map +0 -1
- package/dist/http-handlers.js.map +0 -1
- package/dist/http.js.map +0 -1
- package/dist/issues.js +0 -178
- package/dist/issues.js.map +0 -1
- package/dist/logging.js +0 -203
- package/dist/logging.js.map +0 -1
- package/dist/mcp.js +0 -706
- package/dist/mcp.js.map +0 -1
- package/dist/memory.js +0 -85
- package/dist/memory.js.map +0 -1
- package/dist/orchestrator-decisions.js +0 -331
- package/dist/orchestrator-decisions.js.map +0 -1
- package/dist/orchestrator.js +0 -1569
- package/dist/orchestrator.js.map +0 -1
- package/dist/prompt.js +0 -65
- package/dist/prompt.js.map +0 -1
- package/dist/reconciler/cache.js +0 -65
- package/dist/reconciler/cache.js.map +0 -1
- package/dist/reconciler/index.js +0 -448
- package/dist/reconciler/index.js.map +0 -1
- package/dist/reconciler/ledger.js +0 -131
- package/dist/reconciler/ledger.js.map +0 -1
- package/dist/reconciler/pr-adapters.js +0 -174
- package/dist/reconciler/pr-adapters.js.map +0 -1
- package/dist/reconciler/pr-decide.js.map +0 -1
- package/dist/reconciler/pr.js +0 -422
- package/dist/reconciler/pr.js.map +0 -1
- package/dist/reconciler/types.js +0 -12
- package/dist/reconciler/types.js.map +0 -1
- package/dist/reconciler/vm.js +0 -243
- package/dist/reconciler/vm.js.map +0 -1
- package/dist/reconciler/workspace-defaults.js +0 -83
- package/dist/reconciler/workspace-defaults.js.map +0 -1
- package/dist/reconciler/workspace.js +0 -272
- package/dist/reconciler/workspace.js.map +0 -1
- package/dist/runlog.js +0 -403
- package/dist/runlog.js.map +0 -1
- package/dist/scaffold.js +0 -165
- package/dist/scaffold.js.map +0 -1
- package/dist/trackers/local.js +0 -445
- package/dist/trackers/local.js.map +0 -1
- package/dist/trackers/types.js +0 -10
- package/dist/trackers/types.js.map +0 -1
- package/dist/types.js +0 -3
- package/dist/types.js.map +0 -1
- package/dist/util/clock.js +0 -12
- package/dist/util/clock.js.map +0 -1
- package/dist/util/crypto.js +0 -25
- package/dist/util/crypto.js.map +0 -1
- package/dist/util/frontmatter.js +0 -70
- package/dist/util/frontmatter.js.map +0 -1
- package/dist/util/fs-issues.js +0 -22
- package/dist/util/fs-issues.js.map +0 -1
- package/dist/util/process.js +0 -152
- package/dist/util/process.js.map +0 -1
- package/dist/util/workspace-key.js +0 -10
- package/dist/util/workspace-key.js.map +0 -1
- package/dist/workflow-loader.js +0 -147
- package/dist/workflow-loader.js.map +0 -1
- package/dist/workflow.js +0 -822
- package/dist/workflow.js.map +0 -1
- package/dist/workspace-types.js +0 -8
- package/dist/workspace-types.js.map +0 -1
- package/dist/workspace.js +0 -443
- package/dist/workspace.js.map +0 -1
|
@@ -1,145 +1,42 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import { log } from './logging.js';
|
|
8
|
-
import { writeIssueFile } from './issues.js';
|
|
9
|
-
import { matchRoute, resolvePartialName, extractBearerToken, classifyContentType, checkSteeringCsrf, checkTriageCsrf, extractFormText, extractJsonText, decideCreateIssue, decideTriageTransition, } from './http-handlers.js';
|
|
10
|
-
import { listIssuesFromDisk, readIssueFromDisk, } from './http-disk.js';
|
|
11
|
-
function jsonResponse(res, status, body) {
|
|
12
|
-
const text = JSON.stringify(body);
|
|
13
|
-
res.statusCode = status;
|
|
14
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
15
|
-
res.setHeader('content-length', Buffer.byteLength(text));
|
|
16
|
-
res.end(text);
|
|
17
|
-
}
|
|
18
|
-
function methodNotAllowed(res) {
|
|
19
|
-
jsonResponse(res, 405, { error: { code: 'method_not_allowed', message: 'method not allowed' } });
|
|
20
|
-
}
|
|
21
|
-
function notFound(res, code = 'not_found', message = 'not found') {
|
|
22
|
-
jsonResponse(res, 404, { error: { code, message } });
|
|
23
|
-
}
|
|
24
|
-
function badRequest(res, message) {
|
|
25
|
-
jsonResponse(res, 400, { error: { code: 'bad_request', message } });
|
|
26
|
-
}
|
|
27
|
-
async function readJsonBody(req, maxBytes = 1_000_000) {
|
|
28
|
-
return new Promise((resolve, reject) => {
|
|
29
|
-
const chunks = [];
|
|
30
|
-
let size = 0;
|
|
31
|
-
req.on('data', (chunk) => {
|
|
32
|
-
size += chunk.length;
|
|
33
|
-
if (size > maxBytes) {
|
|
34
|
-
req.destroy();
|
|
35
|
-
reject(new Error('request body too large'));
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
chunks.push(chunk);
|
|
39
|
-
});
|
|
40
|
-
req.on('end', () => {
|
|
41
|
-
const text = Buffer.concat(chunks).toString('utf8');
|
|
42
|
-
if (text.length === 0)
|
|
43
|
-
return resolve({});
|
|
44
|
-
try {
|
|
45
|
-
resolve(JSON.parse(text));
|
|
46
|
-
}
|
|
47
|
-
catch (err) {
|
|
48
|
-
reject(new Error(`invalid JSON: ${err.message}`));
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
req.on('error', reject);
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
// Cross-site form POSTs are "simple" CORS requests that bypass preflight, so an
|
|
55
|
-
// unauthenticated endpoint that accepts form-encoded bodies is CSRFable from any
|
|
56
|
-
// origin. Steering reply uses this check (in addition to requiring HX-Request) to
|
|
57
|
-
// reject form bodies whose Origin doesn't match the Host the request hit.
|
|
1
|
+
// FCIS rewrite — PURE HTML/markdown render layer for the dashboard + issue-detail
|
|
2
|
+
// pages and the HTMX partials. Ported faithfully from the original src/http.ts
|
|
3
|
+
// render functions (renderDashboardHtml, board/column/row partials, header/health/
|
|
4
|
+
// attention partials, renderSteeringInline, renderNewIssuePanel, renderTotalsPartial,
|
|
5
|
+
// renderIssueDetailPage, the minimal Markdown engine, and the small formatters).
|
|
58
6
|
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
res.statusCode = 200;
|
|
91
|
-
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
92
|
-
res.setHeader('cache-control', 'no-store');
|
|
93
|
-
res.end(renderAttentionPartial(p, { errorMessage: message }));
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
jsonResponse(res, jsonStatus, { error: { code, message } });
|
|
97
|
-
}
|
|
98
|
-
async function readTextBody(req, maxBytes = 1_000_000) {
|
|
99
|
-
return new Promise((resolve, reject) => {
|
|
100
|
-
const chunks = [];
|
|
101
|
-
let size = 0;
|
|
102
|
-
req.on('data', (chunk) => {
|
|
103
|
-
size += chunk.length;
|
|
104
|
-
if (size > maxBytes) {
|
|
105
|
-
req.destroy();
|
|
106
|
-
reject(new Error('request body too large'));
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
chunks.push(chunk);
|
|
110
|
-
});
|
|
111
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
112
|
-
req.on('error', reject);
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
async function gatherPartialInputs(orch, view) {
|
|
116
|
-
const trackerRoot = view.trackerRoot ?? '(unset)';
|
|
117
|
-
let diskIssues = [];
|
|
118
|
-
if (view.trackerRoot) {
|
|
119
|
-
try {
|
|
120
|
-
diskIssues = await listIssuesFromDisk(view.trackerRoot);
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
diskIssues = [];
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return {
|
|
127
|
-
workflowName: path.basename(view.workflowPath || 'workflow.md'),
|
|
128
|
-
workflowPath: view.workflowPath || '',
|
|
129
|
-
trackerRoot,
|
|
130
|
-
states: view.states,
|
|
131
|
-
snapshot: orch.snapshot(),
|
|
132
|
-
diskIssues,
|
|
133
|
-
};
|
|
7
|
+
// EVERYTHING here is (data) -> string. ZERO IO, ZERO async, ZERO node: imports, no
|
|
8
|
+
// process / fetch / wall-clock / randomness. Imports ONLY from src/types/**.
|
|
9
|
+
//
|
|
10
|
+
// Two faithful-port notes on purity:
|
|
11
|
+
// • formatTimeShort EXTRACTS the HH:MM:SS field straight out of a supplied ISO
|
|
12
|
+
// string with a regex — no `new Date`, no clock read, no randomness — so it
|
|
13
|
+
// is a pure (string)->(string) transform that the determinism gate accepts.
|
|
14
|
+
// It renders the UTC time component of the timestamp (the upstream values are
|
|
15
|
+
// always `…Z` produced by toISOString), giving a stable 24-hour clock.
|
|
16
|
+
// • basename() comes from the pure core/path.ts helper (no `node:path`) so the
|
|
17
|
+
// workflow name can be derived from a path string purely.
|
|
18
|
+
//
|
|
19
|
+
// The render-input view types (RunningRow / RetryRow / HealthView / PartialInputs
|
|
20
|
+
// / RenderSnapshot / TotalsView / DetailView) live in src/types/http/render.ts
|
|
21
|
+
// per the hexagonal rule; the four the unit test imports are re-exported below so
|
|
22
|
+
// existing importers keep their specifier.
|
|
23
|
+
import { asString, asStringList, asInt, asTimestamp } from '../coerce.js';
|
|
24
|
+
import { basename } from '../path.js';
|
|
25
|
+
import { isInFlowTransition } from '../state-resolve.js';
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// Tiny formatters / escaping
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
const HTML_ESCAPES = {
|
|
30
|
+
'&': '&',
|
|
31
|
+
'<': '<',
|
|
32
|
+
'>': '>',
|
|
33
|
+
'"': '"',
|
|
34
|
+
"'": ''',
|
|
35
|
+
};
|
|
36
|
+
export function escapeHtml(s) {
|
|
37
|
+
return s.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
|
|
134
38
|
}
|
|
135
|
-
|
|
136
|
-
* Map a declared state name to its pill colour class. Active states pulse green
|
|
137
|
-
* (`running`), terminals settle into the muted "done" palette, holdings (Triage)
|
|
138
|
-
* use the same idle palette they always have. Unknown names — issues sitting in a
|
|
139
|
-
* directory that's no longer declared in `states:` — fall back to idle so the
|
|
140
|
-
* dashboard still renders something legible.
|
|
141
|
-
*/
|
|
142
|
-
function pillClassForState(states, stateName) {
|
|
39
|
+
export function pillClassForState(states, stateName) {
|
|
143
40
|
const lower = stateName.toLowerCase();
|
|
144
41
|
const match = states.find((s) => s.name.toLowerCase() === lower);
|
|
145
42
|
if (!match)
|
|
@@ -165,14 +62,14 @@ function trackerStatus(snap) {
|
|
|
165
62
|
return 'working';
|
|
166
63
|
return 'idle';
|
|
167
64
|
}
|
|
168
|
-
function formatTokens(n) {
|
|
65
|
+
export function formatTokens(n) {
|
|
169
66
|
if (n < 1000)
|
|
170
67
|
return String(n);
|
|
171
68
|
if (n < 1_000_000)
|
|
172
69
|
return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
|
|
173
70
|
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
174
71
|
}
|
|
175
|
-
function formatRuntime(seconds) {
|
|
72
|
+
export function formatRuntime(seconds) {
|
|
176
73
|
const s = Math.max(0, Math.round(seconds));
|
|
177
74
|
if (s < 60)
|
|
178
75
|
return `${s}s`;
|
|
@@ -183,42 +80,121 @@ function formatRuntime(seconds) {
|
|
|
183
80
|
const rem = m % 60;
|
|
184
81
|
return rem === 0 ? `${h}h` : `${h}h${rem}m`;
|
|
185
82
|
}
|
|
186
|
-
// `
|
|
187
|
-
//
|
|
83
|
+
// Lifts the `HH:MM:SS` field straight out of an ISO timestamp (the upstream
|
|
84
|
+
// values are always `…Z` from toISOString), giving a stable 24-hour clock such
|
|
85
|
+
// as `14:30:05`. PURE: a regex over the supplied string — no `new Date`, no
|
|
86
|
+
// current-wall-clock read — so the determinism gate accepts it. Returns '' for a
|
|
87
|
+
// nullish or non-ISO input.
|
|
88
|
+
const ISO_TIME_RE = /T(\d{2}:\d{2}:\d{2})/;
|
|
188
89
|
export function formatTimeShort(iso) {
|
|
189
|
-
|
|
190
|
-
return '';
|
|
191
|
-
const d = new Date(iso);
|
|
192
|
-
if (Number.isNaN(d.getTime()))
|
|
193
|
-
return '';
|
|
194
|
-
return d.toLocaleTimeString(undefined, {
|
|
195
|
-
hour: '2-digit',
|
|
196
|
-
minute: '2-digit',
|
|
197
|
-
second: '2-digit',
|
|
198
|
-
hour12: false,
|
|
199
|
-
});
|
|
90
|
+
return (iso && ISO_TIME_RE.exec(iso)?.[1]) || '';
|
|
200
91
|
}
|
|
201
|
-
function truncate(text, max) {
|
|
92
|
+
export function truncate(text, max) {
|
|
202
93
|
if (!text)
|
|
203
94
|
return '';
|
|
204
95
|
const trimmed = text.replace(/\s+/g, ' ').trim();
|
|
205
96
|
return trimmed.length > max ? trimmed.slice(0, max - 1) + '…' : trimmed;
|
|
206
97
|
}
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
function renderHeaderPartial(p) {
|
|
98
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
99
|
+
// Header + system-health strip
|
|
100
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
101
|
+
export function renderHeaderPartial(p) {
|
|
211
102
|
const status = trackerStatus(p.snapshot);
|
|
212
103
|
const statusLabel = status === 'attention' ? 'attention' : status === 'working' ? 'working' : 'idle';
|
|
213
104
|
return `<span class="badge badge-${status}" aria-label="tracker state: ${statusLabel}">${statusLabel}</span>`;
|
|
214
105
|
}
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
106
|
+
// Shorten a gondolin image ref for display: a content-addressed hex digest
|
|
107
|
+
// collapses to its first 12 chars (the conventional "short digest"), a long
|
|
108
|
+
// opaque ref is clipped, and a plain `name:tag` is shown as-is. The full value
|
|
109
|
+
// always rides along in the field title so the exact identifier is never lost.
|
|
110
|
+
function shortenImageRef(image) {
|
|
111
|
+
if (!image)
|
|
112
|
+
return 'unset';
|
|
113
|
+
const hex = /([0-9a-f]{12,64})/i.exec(image);
|
|
114
|
+
if (hex) {
|
|
115
|
+
const short = hex[1].slice(0, 12);
|
|
116
|
+
return image.startsWith('sha256:') ? `sha256:${short}` : short;
|
|
117
|
+
}
|
|
118
|
+
return image.length > 28 ? `${image.slice(0, 27)}…` : image;
|
|
119
|
+
}
|
|
120
|
+
function credentialField(creds) {
|
|
121
|
+
if (creds.length === 0)
|
|
122
|
+
return { key: 'cred', value: 'n/a' };
|
|
123
|
+
const missing = creds.filter((c) => !c.ok).map((c) => c.adapter);
|
|
124
|
+
if (missing.length === 0)
|
|
125
|
+
return { key: 'cred', value: 'ok' };
|
|
126
|
+
return { key: 'cred', value: `missing:${missing.join(',')}`, bad: true };
|
|
127
|
+
}
|
|
128
|
+
function trackerField(writable) {
|
|
129
|
+
if (writable === null)
|
|
130
|
+
return { key: 'tracker', value: 'unset', bad: true };
|
|
131
|
+
return writable
|
|
132
|
+
? { key: 'tracker', value: 'writable' }
|
|
133
|
+
: { key: 'tracker', value: 'readonly', bad: true };
|
|
134
|
+
}
|
|
135
|
+
// One `<State>=<adapter>/<model>` field per active state, with per-state
|
|
136
|
+
// overrides applied (a state inheriting the adapter's default renders
|
|
137
|
+
// `/default`). Reported per state — never a single global `model=` — so the
|
|
138
|
+
// readout cannot imply a default model for a state that overrides it.
|
|
139
|
+
function dispatchFields(dispatch) {
|
|
140
|
+
if (dispatch.length === 0)
|
|
141
|
+
return [{ key: 'dispatch', value: '(no active states)' }];
|
|
142
|
+
return dispatch.map((d) => ({ key: d.state, value: `${d.adapter}/${d.model ?? 'default'}` }));
|
|
143
|
+
}
|
|
144
|
+
function healthFields(health) {
|
|
145
|
+
return [
|
|
146
|
+
...dispatchFields(health.dispatch),
|
|
147
|
+
{ key: 'image', value: shortenImageRef(health.gondolin_image), full: health.gondolin_image ?? 'unset' },
|
|
148
|
+
credentialField(health.credentials),
|
|
149
|
+
trackerField(health.tracker_root_writable),
|
|
150
|
+
{ key: 'poll', value: formatTimeShort(health.last_poll_at) || '—' },
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
function renderHealthField(f) {
|
|
154
|
+
const cls = `hf${f.bad ? ' hf-bad' : ''}`;
|
|
155
|
+
const title = f.full && f.full !== f.value ? ` title="${escapeHtml(f.full)}"` : '';
|
|
156
|
+
return `<span class="${cls}"${title}>${escapeHtml(`${f.key}=${f.value}`)}</span>`;
|
|
157
|
+
}
|
|
158
|
+
// One-time microVM image build banner (issue 206): while the reconcile loop is
|
|
159
|
+
// converting `gondolin.oci_image` into a Gondolin asset, surface a prominent
|
|
160
|
+
// (error-coloured, so it reads as "attention, but expected") banner explaining the
|
|
161
|
+
// hold so "why is nothing dispatching" has an answer. Cleared once the asset caches.
|
|
162
|
+
function renderBuildingImageBanner(building) {
|
|
163
|
+
const phase = building.phase ? ` · ${escapeHtml(building.phase)}` : '';
|
|
164
|
+
return (`<span class="hf hf-bad" title="${escapeHtml(building.ref)}">` +
|
|
165
|
+
`building microVM image (one-time setup) — this can take several minutes${phase}` +
|
|
166
|
+
`</span>`);
|
|
167
|
+
}
|
|
168
|
+
// Failed one-time microVM image build banner (issue 206 review): the conversion
|
|
169
|
+
// threw and dispatch stays HELD (the reconcile gate suppresses an auto-retry for
|
|
170
|
+
// this digest), so surface a PERSISTENT error banner with the failing ref + the
|
|
171
|
+
// converter's message + remediation — otherwise a failed build reads as "nothing is
|
|
172
|
+
// dispatching" with no explanation. Stays until the ref/digest converts or a fresh
|
|
173
|
+
// digest supersedes it.
|
|
174
|
+
function renderFailedImageBanner(failed) {
|
|
175
|
+
const detail = failed.message ? ` — ${escapeHtml(failed.message)}` : '';
|
|
176
|
+
return (`<span class="hf hf-bad" title="${escapeHtml(failed.ref)}">` +
|
|
177
|
+
`microVM image build failed (VM dispatch held)${detail} — ` +
|
|
178
|
+
`fix gondolin.oci_image or push a new image, then restart symphony to retry` +
|
|
179
|
+
`</span>`);
|
|
180
|
+
}
|
|
181
|
+
export function renderHealthPartial(health) {
|
|
182
|
+
if (!health)
|
|
183
|
+
return `<span class="hf">health=unavailable</span>`;
|
|
184
|
+
// An in-flight build takes precedence over a prior failure (they're mutually
|
|
185
|
+
// exclusive at the source, but render defensively): show "building" while
|
|
186
|
+
// converting, the failure banner only once the build cleared but dispatch is held.
|
|
187
|
+
const banner = health.building_image
|
|
188
|
+
? renderBuildingImageBanner(health.building_image)
|
|
189
|
+
: health.failed_image
|
|
190
|
+
? renderFailedImageBanner(health.failed_image)
|
|
191
|
+
: '';
|
|
192
|
+
return banner + healthFields(health).map(renderHealthField).join('');
|
|
193
|
+
}
|
|
194
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
195
|
+
// Top-of-page attention ticker
|
|
196
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
197
|
+
export function renderAttentionPartial(p, opts) {
|
|
222
198
|
const awaiting = p.snapshot.running.filter((r) => r.steering_requested);
|
|
223
199
|
// Continuations are calm post-transition resumes, not failures — keep them
|
|
224
200
|
// off the attention ticker so a clean handoff doesn't read as a stuck retry
|
|
@@ -237,9 +213,7 @@ function renderAttentionPartial(p, opts) {
|
|
|
237
213
|
const links = retrying
|
|
238
214
|
.map((r) => `<a href="#row-${escapeHtml(r.issue_identifier)}" class="tick-id">${escapeHtml(r.issue_identifier)}</a>`)
|
|
239
215
|
.join(' ');
|
|
240
|
-
const label = stuck > 0
|
|
241
|
-
? `${retrying.length} retrying · ${stuck} with error`
|
|
242
|
-
: `${retrying.length} retrying`;
|
|
216
|
+
const label = stuck > 0 ? `${retrying.length} retrying · ${stuck} with error` : `${retrying.length} retrying`;
|
|
243
217
|
segments.push(`<span class="tick tick-retry"><span class="tick-label">${label}</span> ${links}</span>`);
|
|
244
218
|
}
|
|
245
219
|
if (errorMessage) {
|
|
@@ -247,28 +221,20 @@ function renderAttentionPartial(p, opts) {
|
|
|
247
221
|
}
|
|
248
222
|
return segments.join('');
|
|
249
223
|
}
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
// metadata trail, and — for awaiting — an inline question + reply form. Issues
|
|
255
|
-
// sitting in an undeclared state directory (e.g. after a workflow rename) get
|
|
256
|
-
// trailing columns so they stay visible until reconciled rather than silently
|
|
257
|
-
// dropping out of the dashboard.
|
|
258
|
-
function renderBoardPartial(p) {
|
|
224
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
225
|
+
// Board (kanban)
|
|
226
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
227
|
+
export function renderBoardPartial(p) {
|
|
259
228
|
const runningById = new Map();
|
|
260
229
|
for (const r of p.snapshot.running)
|
|
261
230
|
runningById.set(r.issue_identifier, r);
|
|
262
231
|
const retryingById = new Map();
|
|
263
232
|
for (const r of p.snapshot.retrying)
|
|
264
233
|
retryingById.set(r.issue_identifier, r);
|
|
265
|
-
// Group disk issues by lower-cased state name
|
|
266
|
-
// state directories case-insensitively against
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
// `Todo` would be misclassified as an orphan and split off into its own
|
|
270
|
-
// trailing column. `displayName` keeps the on-disk casing for orphan column
|
|
271
|
-
// headers (the only place we need to show a non-declared name).
|
|
234
|
+
// Group disk issues by lower-cased state name (LocalMarkdownTracker matches
|
|
235
|
+
// state directories case-insensitively against declared `states:` keys), so a
|
|
236
|
+
// `todo/` dir under declared `Todo` is not misclassified as an orphan.
|
|
237
|
+
// `displayName` keeps the on-disk casing for orphan column headers.
|
|
272
238
|
const byStateLower = new Map();
|
|
273
239
|
for (const i of p.diskIssues) {
|
|
274
240
|
const key = i.state.toLowerCase();
|
|
@@ -279,22 +245,17 @@ function renderBoardPartial(p) {
|
|
|
279
245
|
byStateLower.set(key, { displayName: i.state, items: [i] });
|
|
280
246
|
}
|
|
281
247
|
// Terminal columns (Done, Cancelled, …) are deliberately omitted from the
|
|
282
|
-
// board
|
|
283
|
-
//
|
|
284
|
-
// dilutes the active surface. Terminal issues stay reachable through their
|
|
285
|
-
// /issues/<id> detail page; a dedicated history surface is the natural place
|
|
286
|
-
// to surface them again once we have something to anchor the row on (a PR
|
|
287
|
-
// link, a final token total, a closed-at timestamp).
|
|
248
|
+
// board: the glance test "is anything stuck, is anything running" doesn't read
|
|
249
|
+
// off finished work. Terminal issues stay reachable through /issues/<id>.
|
|
288
250
|
const declared = p.states
|
|
289
251
|
.filter((state) => state.role !== 'terminal')
|
|
290
252
|
.map((state) => {
|
|
291
253
|
const items = byStateLower.get(state.name.toLowerCase())?.items ?? [];
|
|
292
|
-
return renderColumn(state, items, runningById, retryingById);
|
|
254
|
+
return renderColumn(state, items, runningById, retryingById, p.states);
|
|
293
255
|
});
|
|
294
|
-
// Orphan columns: on-disk state directories whose names are not
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
// left files behind), not a clean archive.
|
|
256
|
+
// Orphan columns: on-disk state directories whose names are not declared.
|
|
257
|
+
// These render even when terminal-looking because they signal an error the
|
|
258
|
+
// operator needs to see (a workflow rename left files behind).
|
|
298
259
|
const declaredLower = new Set(p.states.map((s) => s.name.toLowerCase()));
|
|
299
260
|
const orphans = [];
|
|
300
261
|
for (const [key, entry] of byStateLower) {
|
|
@@ -303,10 +264,25 @@ function renderBoardPartial(p) {
|
|
|
303
264
|
orphans.push({ name: entry.displayName, items: entry.items });
|
|
304
265
|
}
|
|
305
266
|
orphans.sort((a, b) => a.name.localeCompare(b.name));
|
|
306
|
-
const orphanColumns = orphans.map((o) => renderColumn({ name: o.name, role: 'terminal' }, o.items, runningById, retryingById, {
|
|
307
|
-
|
|
267
|
+
const orphanColumns = orphans.map((o) => renderColumn({ name: o.name, role: 'terminal' }, o.items, runningById, retryingById, p.states, {
|
|
268
|
+
orphan: true,
|
|
269
|
+
}));
|
|
270
|
+
const emptyState = p.diskIssues.length === 0 ? renderBoardEmptyState(p.states) : '';
|
|
271
|
+
return `${emptyState}<div class="kanban">${declared.join('')}${orphanColumns.join('')}</div>`;
|
|
272
|
+
}
|
|
273
|
+
// Whole-board empty state (first run): rendered only when the entire tracker
|
|
274
|
+
// holds zero issues. Teaches the two first-run affordances once (column `+` and
|
|
275
|
+
// the on-disk `issues/<FirstActive>/<id>.md` path), then disappears.
|
|
276
|
+
function renderBoardEmptyState(states) {
|
|
277
|
+
const firstActive = states.find((s) => s.role === 'active')?.name ?? 'Todo';
|
|
278
|
+
const esc = escapeHtml(firstActive);
|
|
279
|
+
return `<section class="board-empty" aria-label="no issues yet">
|
|
280
|
+
<p class="be-lead">no issues yet — dispatch your first</p>
|
|
281
|
+
<p class="be-how">press <span class="be-key">+</span> on a column, or add <code>issues/${esc}/<id>.md</code></p>
|
|
282
|
+
<p class="be-note dim">agents pick up <code>${esc}</code> on the next poll</p>
|
|
283
|
+
</section>`;
|
|
308
284
|
}
|
|
309
|
-
function renderColumn(state, items, runningById, retryingById, opts) {
|
|
285
|
+
function renderColumn(state, items, runningById, retryingById, allStates, opts) {
|
|
310
286
|
const orphan = opts?.orphan === true;
|
|
311
287
|
const canAdd = !orphan && state.role !== 'terminal';
|
|
312
288
|
// Sort within a column so anything needing attention floats to the top:
|
|
@@ -334,7 +310,10 @@ function renderColumn(state, items, runningById, retryingById, opts) {
|
|
|
334
310
|
return a.identifier.localeCompare(b.identifier);
|
|
335
311
|
});
|
|
336
312
|
const rowsHtml = sorted
|
|
337
|
-
.map((i) => renderIssueRow(i, state, runningById.get(i.identifier), retryingById.get(i.identifier), {
|
|
313
|
+
.map((i) => renderIssueRow(i, state, runningById.get(i.identifier), retryingById.get(i.identifier), {
|
|
314
|
+
orphan,
|
|
315
|
+
allStates,
|
|
316
|
+
}))
|
|
338
317
|
.join('');
|
|
339
318
|
const addBtn = canAdd
|
|
340
319
|
? `<button type="button" class="col-add"
|
|
@@ -346,7 +325,7 @@ function renderColumn(state, items, runningById, retryingById, opts) {
|
|
|
346
325
|
? `<p class="col-empty">drop into <code>${escapeHtml(state.name)}/</code></p>`
|
|
347
326
|
: '';
|
|
348
327
|
const orphanBadge = orphan
|
|
349
|
-
? `<span class="col-orphan" title="state not declared in workflow.
|
|
328
|
+
? `<span class="col-orphan" title="state not declared in workflow.yaml">undeclared</span>`
|
|
350
329
|
: '';
|
|
351
330
|
return `<section class="col col-${escapeHtml(state.role)}${orphan ? ' col-orphan-wrap' : ''}" data-state="${escapeHtml(state.name)}">
|
|
352
331
|
<header class="col-head">
|
|
@@ -359,19 +338,16 @@ function renderColumn(state, items, runningById, retryingById, opts) {
|
|
|
359
338
|
<div class="col-body">${rowsHtml}${emptyHint}</div>
|
|
360
339
|
</section>`;
|
|
361
340
|
}
|
|
362
|
-
function rowFlags(
|
|
341
|
+
function rowFlags(running, retrying, orphan) {
|
|
363
342
|
const isAwaiting = !!running?.steering_requested;
|
|
364
|
-
// A `continuation` retry is the slot-holding resume of a clean handoff
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
// a transient pill. Only `failure`-backoff retries are "retrying" (issues
|
|
368
|
-
// 146/154; the design system's Three-Pill Rule — running/retrying/idle only).
|
|
343
|
+
// A `continuation` retry is the slot-holding resume of a clean handoff: the
|
|
344
|
+
// card stays "running" across the handoff instead of flashing a transient
|
|
345
|
+
// pill. Only `failure`-backoff retries are "retrying" (Three-Pill Rule).
|
|
369
346
|
const isContinuation = retrying?.kind === 'continuation';
|
|
370
347
|
return {
|
|
371
348
|
isAwaiting,
|
|
372
349
|
isRunning: (!!running || isContinuation) && !isAwaiting,
|
|
373
350
|
isRetrying: !!retrying && !isContinuation,
|
|
374
|
-
isHolding: !orphan && state.role === 'holding',
|
|
375
351
|
orphan,
|
|
376
352
|
};
|
|
377
353
|
}
|
|
@@ -389,10 +365,9 @@ function rowMetaHtml(running, retrying) {
|
|
|
389
365
|
const tokens = formatTokens(running.tokens.total_tokens || 0);
|
|
390
366
|
return `<span class="row-meta">turn ${running.turn_count} · ${escapeHtml(tokens)} tok</span>`;
|
|
391
367
|
}
|
|
392
|
-
// A `continuation` carries no operator-facing meta: the card reads as
|
|
393
|
-
// across the handoff
|
|
394
|
-
//
|
|
395
|
-
// retries show the backoff schedule.
|
|
368
|
+
// A `continuation` carries no operator-facing meta: the card reads as
|
|
369
|
+
// "running" across the handoff, so the slot-holding window stays silent. Only
|
|
370
|
+
// `failure` retries show the backoff schedule.
|
|
396
371
|
if (retrying && retrying.kind !== 'continuation') {
|
|
397
372
|
const dueAt = formatTimeShort(retrying.due_at) || '—';
|
|
398
373
|
return `<span class="row-meta">attempt ${retrying.attempt} · due ${escapeHtml(dueAt)}</span>`;
|
|
@@ -410,21 +385,52 @@ function rowRetryErrHtml(retrying) {
|
|
|
410
385
|
return '';
|
|
411
386
|
return `<div class="row-err">${escapeHtml(truncate(retrying.error, 200))}</div>`;
|
|
412
387
|
}
|
|
413
|
-
|
|
414
|
-
|
|
388
|
+
// The `<option>` list for a move picker: every declared state except `current`,
|
|
389
|
+
// each flagged `data-offflow` when the target is outside `current`'s non-null
|
|
390
|
+
// `allowed_transitions` (the exact off-flow condition the agent path enforces).
|
|
391
|
+
// Shared by the board row picker and the issue-detail picker so the two never
|
|
392
|
+
// drift. Returns '' when `current` is the only declared state.
|
|
393
|
+
function moveTargetOptionsHtml(current, allStates) {
|
|
394
|
+
const fromLower = current.name.toLowerCase();
|
|
395
|
+
const fromAllowed = current.allowed_transitions ?? null;
|
|
396
|
+
return allStates
|
|
397
|
+
.filter((s) => s.name.toLowerCase() !== fromLower)
|
|
398
|
+
.map((s) => {
|
|
399
|
+
const offAttr = isInFlowTransition(fromAllowed, current.name, s.name) ? '' : ' data-offflow="1"';
|
|
400
|
+
const esc = escapeHtml(s.name);
|
|
401
|
+
return `<option value="${esc}"${offAttr}>${esc}</option>`;
|
|
402
|
+
})
|
|
403
|
+
.join('');
|
|
404
|
+
}
|
|
405
|
+
// Per-row move picker (issue 126): a `<select>` of every declared state except
|
|
406
|
+
// the row's own, rendered for every DECLARED row — settled OR mid-flight. On a
|
|
407
|
+
// LIVE row (running / awaiting / retrying) the move is also the operator's cancel
|
|
408
|
+
// verb (issue 189): picking a state stops the in-flight attempt and moves the
|
|
409
|
+
// issue, so the placeholder reads `cancel / move to…` and the select carries
|
|
410
|
+
// `data-live` so the dashboard script confirms before stopping the run. An orphan
|
|
411
|
+
// row's directory is not a declared state to move within, so it is still skipped.
|
|
412
|
+
// Each option carries a server-computed `data-offflow` flag (the target is outside
|
|
413
|
+
// the from-state's non-null `allowed_transitions`); the script confirms before
|
|
414
|
+
// POSTing an off-flow move. The endpoint never blocks — these are soft guardrails.
|
|
415
|
+
function rowMoveActionsHtml(ident, current, f, allStates) {
|
|
416
|
+
if (f.orphan)
|
|
417
|
+
return '';
|
|
418
|
+
if (!allStates || allStates.length === 0)
|
|
419
|
+
return '';
|
|
420
|
+
const options = moveTargetOptionsHtml(current, allStates);
|
|
421
|
+
if (!options)
|
|
415
422
|
return '';
|
|
416
423
|
const enc = encodeURIComponent(ident);
|
|
424
|
+
const live = f.isRunning || f.isAwaiting || f.isRetrying;
|
|
425
|
+
const liveAttr = live ? ' data-live="1"' : '';
|
|
426
|
+
const placeholder = live ? 'cancel / move to…' : 'move to…';
|
|
417
427
|
return `<div class="row-actions">
|
|
418
|
-
<
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
<
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
hx-post="/api/v1/issues/${enc}/discard"
|
|
425
|
-
hx-target="#board" hx-swap="morph:innerHTML">
|
|
426
|
-
<button type="submit" class="ghost-sm danger" title="discard the proposal">discard</button>
|
|
427
|
-
</form>
|
|
428
|
+
<select class="row-move"${liveAttr}
|
|
429
|
+
data-ident="${enc}" data-from="${escapeHtml(current.name)}"
|
|
430
|
+
aria-label="move ${escapeHtml(ident)} to another state">
|
|
431
|
+
<option value="" selected disabled>${placeholder}</option>
|
|
432
|
+
${options}
|
|
433
|
+
</select>
|
|
428
434
|
</div>`;
|
|
429
435
|
}
|
|
430
436
|
function rowClassList(f, retryHasErr) {
|
|
@@ -438,8 +444,8 @@ function rowClassList(f, retryHasErr) {
|
|
|
438
444
|
.filter(Boolean)
|
|
439
445
|
.join(' ');
|
|
440
446
|
}
|
|
441
|
-
function renderIssueRow(i, state, running, retrying, opts) {
|
|
442
|
-
const f = rowFlags(
|
|
447
|
+
export function renderIssueRow(i, state, running, retrying, opts) {
|
|
448
|
+
const f = rowFlags(running, retrying, opts?.orphan === true);
|
|
443
449
|
const escIdent = escapeHtml(i.identifier);
|
|
444
450
|
const href = `/issues/${encodeURIComponent(i.identifier)}`;
|
|
445
451
|
const steering = f.isAwaiting ? renderSteeringInline(running) : '';
|
|
@@ -454,10 +460,13 @@ function renderIssueRow(i, state, running, retrying, opts) {
|
|
|
454
460
|
<a class="row-title" href="${href}">${escapeHtml(i.title)}</a>
|
|
455
461
|
${rowPeekHtml(running, f.isAwaiting)}
|
|
456
462
|
${rowRetryErrHtml(retrying)}
|
|
457
|
-
${
|
|
463
|
+
${rowMoveActionsHtml(i.identifier, state, f, opts?.allStates)}
|
|
458
464
|
${steering}
|
|
459
465
|
</article>`;
|
|
460
466
|
}
|
|
467
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
468
|
+
// Inline steering on awaiting rows
|
|
469
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
461
470
|
function steeringSummaryLabel(hasOriginalTask, hasContext) {
|
|
462
471
|
if (hasOriginalTask && hasContext)
|
|
463
472
|
return 'original task & agent’s context';
|
|
@@ -493,13 +502,18 @@ function steeringExtraPanel(title, body, context) {
|
|
|
493
502
|
</div>
|
|
494
503
|
</details>`;
|
|
495
504
|
}
|
|
496
|
-
function renderSteeringInline(r) {
|
|
505
|
+
export function renderSteeringInline(r) {
|
|
497
506
|
const question = (r.steering_question ?? '').trim() || '(no question text)';
|
|
498
507
|
const context = (r.steering_context ?? '').trim();
|
|
499
508
|
const issueTitle = (r.issue_title ?? '').trim();
|
|
500
509
|
const issueBody = (r.issue_body ?? '').trim();
|
|
501
|
-
// Stable textarea id
|
|
502
|
-
//
|
|
510
|
+
// Stable textarea id so the every-2s board repoll's idiomorph swap REUSES this
|
|
511
|
+
// node (focus + caret intact) rather than recreating it, and so the draft VALUE
|
|
512
|
+
// survives the swap via the board's `ignoreActiveValue` morph config (see
|
|
513
|
+
// renderDashboardHtml). The old `hx-preserve="true"` is gone on purpose: under a
|
|
514
|
+
// morph swap htmx's preserve logic detaches and re-inserts the live textarea on
|
|
515
|
+
// every poll, which blurred it and snapped the caret back to the top, making the
|
|
516
|
+
// box unwritable while a reply was being typed (issue 228).
|
|
503
517
|
const textareaId = `reply-${r.issue_identifier}`;
|
|
504
518
|
return `<div class="steering">
|
|
505
519
|
<div class="steering-q">${renderMarkdown(question)}</div>
|
|
@@ -509,8 +523,7 @@ function renderSteeringInline(r) {
|
|
|
509
523
|
hx-target="#ticker" hx-swap="morph:innerHTML">
|
|
510
524
|
<textarea id="${escapeHtml(textareaId)}" name="text" required
|
|
511
525
|
placeholder="your reply…"
|
|
512
|
-
aria-label="reply to ${escapeHtml(r.issue_identifier)}"
|
|
513
|
-
hx-preserve="true"></textarea>
|
|
526
|
+
aria-label="reply to ${escapeHtml(r.issue_identifier)}"></textarea>
|
|
514
527
|
<div class="reply-row">
|
|
515
528
|
<span class="hint dim">enter to send · shift+enter for newline</span>
|
|
516
529
|
<button type="submit" class="ghost">send reply</button>
|
|
@@ -518,13 +531,10 @@ function renderSteeringInline(r) {
|
|
|
518
531
|
</form>
|
|
519
532
|
</div>`;
|
|
520
533
|
}
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
// stays visible and interactive on the left. Esc closes; backdrop clicks do not
|
|
526
|
-
// (avoid accidental discards of in-progress drafts).
|
|
527
|
-
function renderNewIssuePanel(p) {
|
|
534
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
535
|
+
// Right-side new-issue panel (the One Card)
|
|
536
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
537
|
+
export function renderNewIssuePanel(p) {
|
|
528
538
|
const targets = p.states.filter((s) => s.role !== 'terminal');
|
|
529
539
|
const firstActive = p.states.find((s) => s.role === 'active');
|
|
530
540
|
const defaultState = (firstActive ?? targets[0])?.name ?? '';
|
|
@@ -553,15 +563,15 @@ function renderNewIssuePanel(p) {
|
|
|
553
563
|
</form>
|
|
554
564
|
</aside>`;
|
|
555
565
|
}
|
|
556
|
-
function renderTotalsPartial(p) {
|
|
566
|
+
export function renderTotalsPartial(p) {
|
|
557
567
|
const t = p.snapshot.session_totals;
|
|
558
568
|
if (!t || (t.input_tokens === 0 && t.output_tokens === 0 && t.seconds_running === 0))
|
|
559
569
|
return '';
|
|
560
570
|
return `${formatTokens(t.input_tokens)} in · ${formatTokens(t.output_tokens)} out · ${formatTokens(t.total_tokens)} total · ${formatRuntime(t.seconds_running)} runtime`;
|
|
561
571
|
}
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
572
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
573
|
+
// Dashboard CSS + client JS (hoisted top-level constants — pure presentation).
|
|
574
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
565
575
|
const DASHBOARD_CSS = `
|
|
566
576
|
:root {
|
|
567
577
|
color-scheme: dark;
|
|
@@ -624,6 +634,20 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fo
|
|
|
624
634
|
#header .refresh:hover { border-color: var(--muted); }
|
|
625
635
|
#header .refresh:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
626
636
|
|
|
637
|
+
/* ── system-health strip ─────────────────────────────────────────────── */
|
|
638
|
+
#health {
|
|
639
|
+
display: flex; flex-wrap: wrap; align-items: baseline;
|
|
640
|
+
gap: 0.25rem 1rem;
|
|
641
|
+
padding: 0.35rem 1.25rem;
|
|
642
|
+
background: var(--bench);
|
|
643
|
+
border-bottom: 1px solid var(--rule-soft);
|
|
644
|
+
font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
645
|
+
color: var(--muted);
|
|
646
|
+
font-variant-numeric: tabular-nums;
|
|
647
|
+
}
|
|
648
|
+
#health .hf { color: var(--muted); white-space: nowrap; }
|
|
649
|
+
#health .hf-bad { color: var(--err); }
|
|
650
|
+
|
|
627
651
|
/* ── ticker ──────────────────────────────────────────────────────────── */
|
|
628
652
|
#ticker {
|
|
629
653
|
display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; align-items: center;
|
|
@@ -698,6 +722,23 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fo
|
|
|
698
722
|
}
|
|
699
723
|
.col-empty code { color: var(--muted); }
|
|
700
724
|
|
|
725
|
+
/* ── whole-board empty state (first run) ─────────────────────────────── */
|
|
726
|
+
.board-empty {
|
|
727
|
+
margin: 0 0 1.25rem; padding: 0 0 1rem;
|
|
728
|
+
border-bottom: 1px solid var(--rule-soft);
|
|
729
|
+
display: grid; gap: 0.25rem; max-width: 64ch;
|
|
730
|
+
}
|
|
731
|
+
.board-empty p { margin: 0; line-height: 1.5; }
|
|
732
|
+
.board-empty .be-lead { color: var(--base); }
|
|
733
|
+
.board-empty .be-how { color: var(--muted); }
|
|
734
|
+
.board-empty .be-note { font-size: 0.9em; }
|
|
735
|
+
.board-empty code { color: var(--muted); }
|
|
736
|
+
.board-empty .be-key {
|
|
737
|
+
display: inline-block; padding: 0 0.35rem;
|
|
738
|
+
border: 1px solid var(--rule-firm); border-radius: 4px;
|
|
739
|
+
color: var(--muted);
|
|
740
|
+
}
|
|
741
|
+
|
|
701
742
|
/* ── row ─────────────────────────────────────────────────────────────── */
|
|
702
743
|
.row {
|
|
703
744
|
padding: 0.55rem 0.6rem; margin: 0 -0.6rem;
|
|
@@ -748,7 +789,18 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fo
|
|
|
748
789
|
overflow-wrap: anywhere; word-break: break-word;
|
|
749
790
|
}
|
|
750
791
|
.row-actions { display: flex; gap: 0.4rem; margin-top: 0.2rem; }
|
|
751
|
-
|
|
792
|
+
/* per-row move picker (issue 126): a compact select offering every other
|
|
793
|
+
declared state; off-flow options are confirmed in the client before POST. */
|
|
794
|
+
.row-move {
|
|
795
|
+
background: transparent; color: var(--muted);
|
|
796
|
+
border: 1px solid var(--rule-firm); border-radius: 3px;
|
|
797
|
+
padding: 0.18rem 0.5rem; font: inherit; font-size: 0.82em; cursor: pointer;
|
|
798
|
+
max-width: 100%;
|
|
799
|
+
transition: color 160ms cubic-bezier(.22,1,.36,1), border-color 160ms cubic-bezier(.22,1,.36,1);
|
|
800
|
+
}
|
|
801
|
+
.row-move:hover { color: var(--strong); border-color: var(--muted); }
|
|
802
|
+
.row-move:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
803
|
+
.row-move:disabled { opacity: 0.6; cursor: default; }
|
|
752
804
|
.ghost {
|
|
753
805
|
background: var(--chip); color: var(--base);
|
|
754
806
|
border: 1px solid var(--rule-firm); border-radius: 4px;
|
|
@@ -757,15 +809,6 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fo
|
|
|
757
809
|
}
|
|
758
810
|
.ghost:hover { border-color: var(--muted); }
|
|
759
811
|
.ghost:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
760
|
-
.ghost-sm {
|
|
761
|
-
background: transparent; color: var(--muted);
|
|
762
|
-
border: 1px solid var(--rule-firm); border-radius: 3px;
|
|
763
|
-
padding: 0.18rem 0.6rem; font: inherit; font-size: 0.82em; cursor: pointer;
|
|
764
|
-
transition: color 160ms cubic-bezier(.22,1,.36,1), border-color 160ms cubic-bezier(.22,1,.36,1);
|
|
765
|
-
}
|
|
766
|
-
.ghost-sm:hover { color: var(--strong); border-color: var(--muted); }
|
|
767
|
-
.ghost-sm.danger:hover { color: var(--err); border-color: var(--err); }
|
|
768
|
-
.ghost-sm:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
769
812
|
|
|
770
813
|
/* ── inline steering on awaiting rows ────────────────────────────────── */
|
|
771
814
|
.steering {
|
|
@@ -932,9 +975,54 @@ footer.totals:empty { display: none; }
|
|
|
932
975
|
.htmx-request .refresh { opacity: 0.6; }
|
|
933
976
|
.htmx-settling .row-peek { opacity: 0.85; }
|
|
934
977
|
`;
|
|
935
|
-
//
|
|
936
|
-
//
|
|
978
|
+
// Shared client move-submit logic, embedded by BOTH the dashboard board picker
|
|
979
|
+
// (event-delegated — the board morphs every poll) and the issue-detail picker
|
|
980
|
+
// (one static select, page reloads). Defines `__symphonyMove(sel, onSuccess)`:
|
|
981
|
+
// confirm first when the move stops a live run (issue 189: a `data-live` select)
|
|
982
|
+
// and/or the chosen option is off-flow, POST the move (the server never blocks —
|
|
983
|
+
// soft guardrail), then run `onSuccess`. The two warnings collapse into ONE
|
|
984
|
+
// confirm so a live off-flow move never double-prompts. Factored out so the
|
|
985
|
+
// confirm text and the POST shape can't drift between the two pages.
|
|
986
|
+
const MOVE_SUBMIT_JS = `
|
|
987
|
+
async function __symphonyMove(sel, onSuccess) {
|
|
988
|
+
const target = sel.value;
|
|
989
|
+
if (!target) return;
|
|
990
|
+
const ident = sel.getAttribute('data-ident') || '';
|
|
991
|
+
const from = sel.getAttribute('data-from') || '';
|
|
992
|
+
const opt = sel.options[sel.selectedIndex];
|
|
993
|
+
const offFlow = !!opt && opt.getAttribute('data-offflow') === '1';
|
|
994
|
+
const live = sel.getAttribute('data-live') === '1';
|
|
995
|
+
const decId = decodeURIComponent(ident);
|
|
996
|
+
let warn = '';
|
|
997
|
+
if (live) warn = 'Stop the in-flight attempt for ' + decId + ' and move it to "' + target + '"?';
|
|
998
|
+
else if (offFlow) warn = 'Move ' + decId + ' to "' + target + '"?';
|
|
999
|
+
if (warn && offFlow) warn += ' That is outside the declared flow from "' + from + '".';
|
|
1000
|
+
if (warn && !window.confirm(warn)) {
|
|
1001
|
+
sel.value = '';
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
sel.disabled = true;
|
|
1005
|
+
try {
|
|
1006
|
+
const res = await fetch('/api/v1/issues/' + ident + '/move', {
|
|
1007
|
+
method: 'POST',
|
|
1008
|
+
headers: { 'content-type': 'application/json' },
|
|
1009
|
+
body: JSON.stringify({ state: target, from_state: from }),
|
|
1010
|
+
});
|
|
1011
|
+
if (!res.ok) {
|
|
1012
|
+
const data = await res.json().catch(() => null);
|
|
1013
|
+
throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status));
|
|
1014
|
+
}
|
|
1015
|
+
onSuccess();
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
sel.disabled = false;
|
|
1018
|
+
sel.value = '';
|
|
1019
|
+
window.alert('move failed: ' + err.message);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
`;
|
|
1023
|
+
// Dashboard new-issue panel controller. Top-level constant — pure presentation.
|
|
937
1024
|
const DASHBOARD_SCRIPT = `
|
|
1025
|
+
${MOVE_SUBMIT_JS}
|
|
938
1026
|
(() => {
|
|
939
1027
|
const $ = (id) => document.getElementById(id);
|
|
940
1028
|
const panel = $('new-panel');
|
|
@@ -989,6 +1077,18 @@ const DASHBOARD_SCRIPT = `
|
|
|
989
1077
|
}
|
|
990
1078
|
});
|
|
991
1079
|
|
|
1080
|
+
// Per-row move picker (issue 126): selecting a target state moves the issue
|
|
1081
|
+
// (event-delegated because the board morphs every poll). On success the board
|
|
1082
|
+
// is refreshed; __symphonyMove handles the off-flow confirm + the POST.
|
|
1083
|
+
document.addEventListener('change', (ev) => {
|
|
1084
|
+
const sel = ev.target;
|
|
1085
|
+
if (!(sel instanceof HTMLSelectElement) || !sel.classList.contains('row-move')) return;
|
|
1086
|
+
__symphonyMove(sel, () => {
|
|
1087
|
+
fetch('/api/v1/refresh', { method: 'POST' }).catch(() => {});
|
|
1088
|
+
document.body.dispatchEvent(new CustomEvent('refreshed', { bubbles: true }));
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
|
|
992
1092
|
form.addEventListener('submit', async (ev) => {
|
|
993
1093
|
ev.preventDefault();
|
|
994
1094
|
setMsg('creating…');
|
|
@@ -1017,7 +1117,7 @@ const DASHBOARD_SCRIPT = `
|
|
|
1017
1117
|
});
|
|
1018
1118
|
})();
|
|
1019
1119
|
`;
|
|
1020
|
-
function renderDashboardHtml(p) {
|
|
1120
|
+
export function renderDashboardHtml(p, health) {
|
|
1021
1121
|
return `<!doctype html>
|
|
1022
1122
|
<html lang="en"><head>
|
|
1023
1123
|
<meta charset="utf-8">
|
|
@@ -1041,13 +1141,17 @@ function renderDashboardHtml(p) {
|
|
|
1041
1141
|
aria-label="refresh now" title="poll & reconcile">⟳</button>
|
|
1042
1142
|
</header>
|
|
1043
1143
|
|
|
1144
|
+
<div id="health" aria-label="system health"
|
|
1145
|
+
hx-get="/api/v1/partials/health" hx-trigger="every 2s, refreshed from:body"
|
|
1146
|
+
hx-swap="morph:innerHTML">${renderHealthPartial(health)}</div>
|
|
1147
|
+
|
|
1044
1148
|
<div id="ticker"
|
|
1045
1149
|
hx-get="/api/v1/partials/attention" hx-trigger="every 2s, refreshed from:body"
|
|
1046
1150
|
hx-swap="morph:innerHTML">${renderAttentionPartial(p)}</div>
|
|
1047
1151
|
|
|
1048
1152
|
<main id="board"
|
|
1049
1153
|
hx-get="/api/v1/partials/board" hx-trigger="every 2s, refreshed from:body"
|
|
1050
|
-
hx-swap=
|
|
1154
|
+
hx-swap='morph:{"morphStyle":"innerHTML","ignoreActiveValue":true,"restoreFocus":true}'>${renderBoardPartial(p)}</main>
|
|
1051
1155
|
|
|
1052
1156
|
${renderNewIssuePanel(p)}
|
|
1053
1157
|
|
|
@@ -1059,9 +1163,6 @@ ${renderNewIssuePanel(p)}
|
|
|
1059
1163
|
|
|
1060
1164
|
</body></html>`;
|
|
1061
1165
|
}
|
|
1062
|
-
function escapeHtml(s) {
|
|
1063
|
-
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
1064
|
-
}
|
|
1065
1166
|
const FENCE_OPEN_RE = /^```([\w-]*)\s*$/;
|
|
1066
1167
|
const FENCE_CLOSE_RE = /^```\s*$/;
|
|
1067
1168
|
const HEADER_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
@@ -1116,12 +1217,12 @@ function matchBlockquote(lines, i) {
|
|
|
1116
1217
|
return { html: `<blockquote>${renderMarkdown(buf.join('\n'))}</blockquote>`, nextI: j };
|
|
1117
1218
|
}
|
|
1118
1219
|
function isBlockBoundary(line) {
|
|
1119
|
-
return line.trim() === ''
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1220
|
+
return (line.trim() === '' ||
|
|
1221
|
+
FENCE_OPEN_RE.test(line) ||
|
|
1222
|
+
/^#{1,6}\s+/.test(line) ||
|
|
1223
|
+
UL_RE.test(line) ||
|
|
1224
|
+
OL_RE.test(line) ||
|
|
1225
|
+
BLOCKQUOTE_RE.test(line));
|
|
1125
1226
|
}
|
|
1126
1227
|
function matchParagraph(lines, i) {
|
|
1127
1228
|
const para = [];
|
|
@@ -1141,23 +1242,23 @@ export function renderMarkdown(input) {
|
|
|
1141
1242
|
i++;
|
|
1142
1243
|
continue;
|
|
1143
1244
|
}
|
|
1144
|
-
const m = matchFencedCode(lines, i)
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1245
|
+
const m = matchFencedCode(lines, i) ??
|
|
1246
|
+
matchHeader(lines, i) ??
|
|
1247
|
+
matchList(lines, i, UL_RE, UL_ITEM_RE, 'ul') ??
|
|
1248
|
+
matchList(lines, i, OL_RE, OL_ITEM_RE, 'ol') ??
|
|
1249
|
+
matchBlockquote(lines, i) ??
|
|
1250
|
+
matchParagraph(lines, i);
|
|
1150
1251
|
blocks.push(m.html);
|
|
1151
1252
|
i = m.nextI;
|
|
1152
1253
|
}
|
|
1153
1254
|
return blocks.join('\n');
|
|
1154
1255
|
}
|
|
1155
|
-
function renderInlineMarkdown(input) {
|
|
1256
|
+
export function renderInlineMarkdown(input) {
|
|
1156
1257
|
const codes = [];
|
|
1157
1258
|
let text = input.replace(/`([^`\n]+)`/g, (_m, code) => {
|
|
1158
1259
|
const idx = codes.length;
|
|
1159
1260
|
codes.push(`<code>${escapeHtml(code)}</code>`);
|
|
1160
|
-
return
|
|
1261
|
+
return ` C${idx} `;
|
|
1161
1262
|
});
|
|
1162
1263
|
text = escapeHtml(text);
|
|
1163
1264
|
text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
|
|
@@ -1170,38 +1271,22 @@ function renderInlineMarkdown(input) {
|
|
|
1170
1271
|
text = text.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, '$1<em>$2</em>');
|
|
1171
1272
|
text = text.replace(/(^|[^_\w])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
|
|
1172
1273
|
text = text.replace(/\n/g, '<br>');
|
|
1173
|
-
text = text.replace(
|
|
1274
|
+
text = text.replace(/ C(\d+) /g, (_m, idx) => codes[Number(idx)]);
|
|
1174
1275
|
return text;
|
|
1175
1276
|
}
|
|
1176
1277
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1177
|
-
// Issue-detail page.
|
|
1178
|
-
//
|
|
1179
|
-
//
|
|
1180
|
-
// through the same minimal Markdown engine the steering panel uses. Read-only
|
|
1181
|
-
// — actions live on the dashboard, not here. Follows the design system's
|
|
1182
|
-
// quiet-workshop rules: flat panels, tabular numerics, no shadows, status pill
|
|
1183
|
-
// uses the existing palette.
|
|
1278
|
+
// Issue-detail page. Read-only HTML view of one on-disk issue: front-matter
|
|
1279
|
+
// metadata (labels, priority, blocked_by, provenance, timestamps) + the Markdown
|
|
1280
|
+
// body rendered through the same minimal engine the steering panel uses.
|
|
1184
1281
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
function asNumber(v) {
|
|
1194
|
-
if (typeof v === 'number' && Number.isFinite(v))
|
|
1195
|
-
return v;
|
|
1196
|
-
return null;
|
|
1197
|
-
}
|
|
1198
|
-
function formatTimestamp(v) {
|
|
1199
|
-
if (typeof v === 'string' && v.length > 0)
|
|
1200
|
-
return v;
|
|
1201
|
-
if (v instanceof Date)
|
|
1202
|
-
return v.toISOString();
|
|
1203
|
-
return null;
|
|
1204
|
-
}
|
|
1282
|
+
// Front-matter coercers (asString/asStringList/asInt/asTimestamp) are the
|
|
1283
|
+
// canonical ones from core/coerce.ts. NOTE(drift resolved): the old local copies
|
|
1284
|
+
// here carried a `v.length > 0` length-guard on asString and `formatTimestamp`
|
|
1285
|
+
// that the issue parser did not; the canonical asString has NO length-guard
|
|
1286
|
+
// (matching issue/parse.ts:toIssue), so the detail page now coerces front-matter
|
|
1287
|
+
// IDENTICALLY to the way an Issue is normalized — they can no longer drift. The
|
|
1288
|
+
// old formatTimestamp folds into asTimestamp (which DID keep the empty-string→
|
|
1289
|
+
// null guard). See core/coerce.ts header + tests/core/coerce.test.ts.
|
|
1205
1290
|
function detailRow(label, value) {
|
|
1206
1291
|
return `<div class="meta-row"><dt>${escapeHtml(label)}</dt><dd>${value}</dd></div>`;
|
|
1207
1292
|
}
|
|
@@ -1239,18 +1324,22 @@ function detailTimestampRow(label, ts) {
|
|
|
1239
1324
|
return detailRow(label, `<span class="num">${escapeHtml(ts)}</span>`);
|
|
1240
1325
|
}
|
|
1241
1326
|
// Pill colour follows the declared role: active → running, terminal → done,
|
|
1242
|
-
// holding → idle. An undeclared state
|
|
1243
|
-
// into a name not in `states:`) falls back to idle so the detail page still
|
|
1244
|
-
// renders legibly.
|
|
1327
|
+
// holding → idle. An undeclared state falls back to idle.
|
|
1245
1328
|
function buildDetailMetaRows(fm, stateClass, stateName) {
|
|
1246
1329
|
const branchName = asString(fm['branch_name']);
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
const
|
|
1330
|
+
// priority via asInt (the Issue-normalization coercer), so a fractional value
|
|
1331
|
+
// truncates here exactly as it does in issue/parse.ts:toIssue.
|
|
1332
|
+
const priority = asInt(fm['priority']);
|
|
1333
|
+
const createdAt = asTimestamp(fm['created_at']);
|
|
1334
|
+
const updatedAt = asTimestamp(fm['updated_at']);
|
|
1250
1335
|
const showUpdated = updatedAt && updatedAt !== createdAt ? updatedAt : null;
|
|
1336
|
+
// NOTE(drift resolved): the Issue normalization (issue/parse.ts:toIssue)
|
|
1337
|
+
// lowercases labels; this detail page used to render them un-cased. Lowercase
|
|
1338
|
+
// here too so the detail page and the board/Issue agree. Pinned by render.test.
|
|
1339
|
+
const labels = asStringList(fm['labels']).map((l) => l.toLowerCase());
|
|
1251
1340
|
return [
|
|
1252
1341
|
detailRow('state', `<span class="pill ${stateClass}">${escapeHtml(stateName)}</span>`),
|
|
1253
|
-
detailLabelsRow(
|
|
1342
|
+
detailLabelsRow(labels),
|
|
1254
1343
|
priority !== null ? detailRow('priority', `<span class="num">${escapeHtml(String(priority))}</span>`) : '',
|
|
1255
1344
|
detailBlockedByRow(asStringList(fm['blocked_by'])),
|
|
1256
1345
|
branchName ? detailRow('branch', `<code>${escapeHtml(branchName)}</code>`) : '',
|
|
@@ -1258,11 +1347,10 @@ function buildDetailMetaRows(fm, stateClass, stateName) {
|
|
|
1258
1347
|
detailTimestampRow('created', createdAt),
|
|
1259
1348
|
detailTimestampRow('updated', showUpdated),
|
|
1260
1349
|
detailProposedByRow(asString(fm['proposed_by'])),
|
|
1261
|
-
detailTimestampRow('proposed at',
|
|
1350
|
+
detailTimestampRow('proposed at', asTimestamp(fm['proposed_at'])),
|
|
1262
1351
|
].join('');
|
|
1263
1352
|
}
|
|
1264
|
-
// Issue-detail CSS, hoisted out of renderIssueDetailPage
|
|
1265
|
-
// within the imperative-shell line budget. Pure presentation content.
|
|
1353
|
+
// Issue-detail CSS, hoisted out of renderIssueDetailPage. Pure presentation.
|
|
1266
1354
|
const DETAIL_PAGE_CSS = `
|
|
1267
1355
|
:root {
|
|
1268
1356
|
color-scheme: dark;
|
|
@@ -1349,6 +1437,24 @@ main {
|
|
|
1349
1437
|
align-self: baseline; padding-top: 0.15rem;
|
|
1350
1438
|
}
|
|
1351
1439
|
.issue-meta dd { margin: 0; color: var(--base); }
|
|
1440
|
+
|
|
1441
|
+
/* operator move control (issue 126): terminal issues are off the board, so the
|
|
1442
|
+
detail page is where they stay reachable for a move. Mirrors the board's
|
|
1443
|
+
.row-move select; an off-flow target is confirmed client-side before POST. */
|
|
1444
|
+
.detail-move { display: flex; align-items: center; gap: 0.6rem; margin: 0 0 1.4rem; }
|
|
1445
|
+
.detail-move-label {
|
|
1446
|
+
color: var(--dim); font-size: 0.85em;
|
|
1447
|
+
letter-spacing: 0.05em; text-transform: uppercase;
|
|
1448
|
+
}
|
|
1449
|
+
.detail-move .row-move {
|
|
1450
|
+
background: transparent; color: var(--muted);
|
|
1451
|
+
border: 1px solid var(--rule-firm); border-radius: 3px;
|
|
1452
|
+
padding: 0.2rem 0.55rem; font: inherit; font-size: 0.85em; cursor: pointer;
|
|
1453
|
+
transition: color 160ms cubic-bezier(.22,1,.36,1), border-color 160ms cubic-bezier(.22,1,.36,1);
|
|
1454
|
+
}
|
|
1455
|
+
.detail-move .row-move:hover { color: var(--strong); border-color: var(--muted); }
|
|
1456
|
+
.detail-move .row-move:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
1457
|
+
.detail-move .row-move:disabled { opacity: 0.6; cursor: default; }
|
|
1352
1458
|
.label-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
|
1353
1459
|
.label-chip {
|
|
1354
1460
|
display: inline-block; padding: 0.05rem 0.5rem; border-radius: 4px;
|
|
@@ -1407,15 +1513,61 @@ h2.section-title {
|
|
|
1407
1513
|
}
|
|
1408
1514
|
.empty { padding: 0.5rem 0; }
|
|
1409
1515
|
`;
|
|
1410
|
-
|
|
1516
|
+
// Operator move control on the issue-detail page (issue 126). The board's move
|
|
1517
|
+
// picker only renders on board ROWS, and terminal columns are deliberately kept
|
|
1518
|
+
// off the board (the glance view skips finished work) — so a terminal issue
|
|
1519
|
+
// (Done/Cancelled) has no board picker. The detail page is where terminal issues
|
|
1520
|
+
// stay reachable, so the move control lives here for them, closing the gap that
|
|
1521
|
+
// the operator couldn't move an issue OUT of a terminal state from the dashboard.
|
|
1522
|
+
//
|
|
1523
|
+
// Scoped to terminal states on purpose: a terminal issue is never mid-flight
|
|
1524
|
+
// (dispatch stops at terminal states), so this control needs no running-state
|
|
1525
|
+
// guard — whereas a non-terminal issue might be running, which is exactly why the
|
|
1526
|
+
// board picker (which has the running-state info to skip mid-flight rows) owns
|
|
1527
|
+
// those. The endpoint is the same generalized `/move`, so an off-flow target is
|
|
1528
|
+
// confirmed and `off_flow` echoed identically; a re-opened active target gets the
|
|
1529
|
+
// same immediate-pickup nudge approve did.
|
|
1530
|
+
function detailMoveControlHtml(identifier, stateName, states) {
|
|
1531
|
+
const current = states.find((s) => s.name.toLowerCase() === stateName.toLowerCase());
|
|
1532
|
+
if (!current || current.role !== 'terminal')
|
|
1533
|
+
return '';
|
|
1534
|
+
const options = moveTargetOptionsHtml(current, states);
|
|
1535
|
+
if (!options)
|
|
1536
|
+
return '';
|
|
1537
|
+
const enc = encodeURIComponent(identifier);
|
|
1538
|
+
return `<div class="detail-move">
|
|
1539
|
+
<label class="detail-move-label" for="detail-move-select">move out of ${escapeHtml(current.name)}</label>
|
|
1540
|
+
<select class="row-move" id="detail-move-select"
|
|
1541
|
+
data-ident="${enc}" data-from="${escapeHtml(current.name)}"
|
|
1542
|
+
aria-label="move ${escapeHtml(identifier)} to another state">
|
|
1543
|
+
<option value="" selected disabled>move to…</option>
|
|
1544
|
+
${options}
|
|
1545
|
+
</select>
|
|
1546
|
+
</div>`;
|
|
1547
|
+
}
|
|
1548
|
+
// Issue-detail client script: binds the single move <select> (rendered for
|
|
1549
|
+
// terminal issues only) to the shared __symphonyMove, reloading on success so the
|
|
1550
|
+
// new state pill re-renders. Self-contained — the detail page does NOT load the
|
|
1551
|
+
// dashboard script (which wires the board + new-issue panel that don't exist here).
|
|
1552
|
+
const DETAIL_PAGE_SCRIPT = `
|
|
1553
|
+
${MOVE_SUBMIT_JS}
|
|
1554
|
+
(() => {
|
|
1555
|
+
const sel = document.getElementById('detail-move-select');
|
|
1556
|
+
if (sel instanceof HTMLSelectElement) {
|
|
1557
|
+
sel.addEventListener('change', () => __symphonyMove(sel, () => location.reload()));
|
|
1558
|
+
}
|
|
1559
|
+
})();
|
|
1560
|
+
`;
|
|
1561
|
+
export function renderIssueDetailPage(issue, view) {
|
|
1411
1562
|
const fm = issue.frontMatter;
|
|
1412
1563
|
const title = asString(fm['title']) ?? issue.identifier;
|
|
1413
1564
|
const stateClass = pillClassForState(view.states, issue.state);
|
|
1414
1565
|
const metaRows = buildDetailMetaRows(fm, stateClass, issue.state);
|
|
1566
|
+
const moveControl = detailMoveControlHtml(issue.identifier, issue.state, view.states);
|
|
1415
1567
|
const bodyHtml = issue.body.length > 0
|
|
1416
1568
|
? `<div class="issue-body">${renderMarkdown(issue.body)}</div>`
|
|
1417
1569
|
: `<p class="empty dim">no description</p>`;
|
|
1418
|
-
const workflowName =
|
|
1570
|
+
const workflowName = basename(view.workflowPath || 'workflow.yaml');
|
|
1419
1571
|
return `<!doctype html>
|
|
1420
1572
|
<html lang="en"><head>
|
|
1421
1573
|
<meta charset="utf-8">
|
|
@@ -1441,436 +1593,18 @@ function renderIssueDetailPage(issue, view) {
|
|
|
1441
1593
|
</section>
|
|
1442
1594
|
|
|
1443
1595
|
<dl class="issue-meta">${metaRows}</dl>
|
|
1596
|
+
${moveControl}
|
|
1444
1597
|
|
|
1445
1598
|
<h2 class="section-title">description</h2>
|
|
1446
1599
|
${bodyHtml}
|
|
1447
1600
|
|
|
1448
1601
|
<p class="file-path" title="path on disk">${escapeHtml(issue.filePath)}</p>
|
|
1449
1602
|
</main>
|
|
1450
|
-
|
|
1603
|
+
${moveControl ? `\n<script>${DETAIL_PAGE_SCRIPT}</script>\n` : ''}
|
|
1451
1604
|
</body></html>`;
|
|
1452
1605
|
}
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
// Returns the *actually bound* port (relevant for --port 0, where the kernel picks an
|
|
1456
|
-
// ephemeral port) so callers can advertise the live address.
|
|
1457
|
-
export async function startHttpServer(orch, opts) {
|
|
1458
|
-
const server = createServer((req, res) => {
|
|
1459
|
-
void handleRequest(req, res, orch, opts).catch((err) => {
|
|
1460
|
-
log.error('http handler error', { error: err.message });
|
|
1461
|
-
try {
|
|
1462
|
-
jsonResponse(res, 500, {
|
|
1463
|
-
error: { code: 'internal_error', message: err.message },
|
|
1464
|
-
});
|
|
1465
|
-
}
|
|
1466
|
-
catch {
|
|
1467
|
-
/* response already started */
|
|
1468
|
-
}
|
|
1469
|
-
});
|
|
1470
|
-
});
|
|
1471
|
-
server.on('clientError', (err, socket) => {
|
|
1472
|
-
log.debug('http client error', { error: err.message });
|
|
1473
|
-
try {
|
|
1474
|
-
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
1475
|
-
}
|
|
1476
|
-
catch {
|
|
1477
|
-
/* socket may already be closed */
|
|
1478
|
-
}
|
|
1479
|
-
});
|
|
1480
|
-
let boundPort = opts.port;
|
|
1481
|
-
await new Promise((resolve, reject) => {
|
|
1482
|
-
const onError = (err) => {
|
|
1483
|
-
server.removeListener('listening', onListening);
|
|
1484
|
-
reject(err);
|
|
1485
|
-
};
|
|
1486
|
-
const onListening = () => {
|
|
1487
|
-
server.removeListener('error', onError);
|
|
1488
|
-
const addr = server.address();
|
|
1489
|
-
const port = typeof addr === 'object' && addr ? addr.port : opts.port;
|
|
1490
|
-
boundPort = port;
|
|
1491
|
-
log.info('http server listening', { host: opts.host, port });
|
|
1492
|
-
resolve();
|
|
1493
|
-
};
|
|
1494
|
-
server.once('error', onError);
|
|
1495
|
-
server.once('listening', onListening);
|
|
1496
|
-
server.listen(opts.port, opts.host);
|
|
1497
|
-
});
|
|
1498
|
-
// After bind succeeds, install a permanent error handler so later runtime errors
|
|
1499
|
-
// (sockets resetting, ENOTCONN, etc.) are logged rather than crashing the process.
|
|
1500
|
-
server.on('error', (err) => {
|
|
1501
|
-
log.warn('http server error', { error: err.message });
|
|
1502
|
-
});
|
|
1503
|
-
return {
|
|
1504
|
-
port: boundPort,
|
|
1505
|
-
close: () => new Promise((resolve) => {
|
|
1506
|
-
server.close(() => resolve());
|
|
1507
|
-
}),
|
|
1508
|
-
};
|
|
1509
|
-
}
|
|
1510
|
-
async function handleRequest(req, res, orch, opts) {
|
|
1511
|
-
// URL parsing inside the handler so a malformed Host header doesn't crash the listener.
|
|
1512
|
-
let pathname;
|
|
1513
|
-
try {
|
|
1514
|
-
pathname = new URL(req.url ?? '/', 'http://symphony.local').pathname;
|
|
1515
|
-
}
|
|
1516
|
-
catch {
|
|
1517
|
-
return badRequest(res, 'invalid request URL');
|
|
1518
|
-
}
|
|
1519
|
-
const method = (req.method ?? 'GET').toUpperCase();
|
|
1520
|
-
const view = opts.getTrackerView();
|
|
1521
|
-
const route = matchRoute(pathname);
|
|
1522
|
-
const handler = ROUTE_HANDLERS[route.kind];
|
|
1523
|
-
await handler({ req, res, orch, opts, view, method, route });
|
|
1524
|
-
}
|
|
1525
|
-
async function handleDashboard(ctx) {
|
|
1526
|
-
if (ctx.method !== 'GET')
|
|
1527
|
-
return methodNotAllowed(ctx.res);
|
|
1528
|
-
const p = await gatherPartialInputs(ctx.orch, ctx.view);
|
|
1529
|
-
ctx.res.statusCode = 200;
|
|
1530
|
-
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1531
|
-
ctx.res.end(renderDashboardHtml(p));
|
|
1532
|
-
}
|
|
1533
|
-
// Static preview for impeccable live mode. The file under .impeccable/preview/ is a
|
|
1534
|
-
// captured snapshot of the dashboard with polling disabled, used as a variant playground.
|
|
1535
|
-
// Read on every request so live-wrap edits land immediately.
|
|
1536
|
-
async function handlePreview(ctx) {
|
|
1537
|
-
if (ctx.method !== 'GET')
|
|
1538
|
-
return methodNotAllowed(ctx.res);
|
|
1539
|
-
try {
|
|
1540
|
-
const html = await readFile('.impeccable/preview/dashboard.html', 'utf8');
|
|
1541
|
-
ctx.res.statusCode = 200;
|
|
1542
|
-
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1543
|
-
ctx.res.setHeader('cache-control', 'no-store');
|
|
1544
|
-
ctx.res.end(html);
|
|
1545
|
-
}
|
|
1546
|
-
catch (err) {
|
|
1547
|
-
notFound(ctx.res, 'preview_missing', `preview not available: ${err.message}`);
|
|
1548
|
-
}
|
|
1549
|
-
}
|
|
1550
|
-
// HTMX partials. Each region polls its own endpoint at 2s; this is what the dashboard
|
|
1551
|
-
// <section hx-get=...> elements consume. They return only the inner HTML; the outer
|
|
1552
|
-
// wrapper is in the dashboard shell.
|
|
1553
|
-
async function handlePartial(ctx) {
|
|
1554
|
-
if (ctx.method !== 'GET')
|
|
1555
|
-
return methodNotAllowed(ctx.res);
|
|
1556
|
-
const name = resolvePartialName(ctx.route.slug);
|
|
1557
|
-
if (!name) {
|
|
1558
|
-
return notFound(ctx.res, 'partial_not_found', `partial ${ctx.route.slug} does not exist`);
|
|
1559
|
-
}
|
|
1560
|
-
const p = await gatherPartialInputs(ctx.orch, ctx.view);
|
|
1561
|
-
const body = name === 'header' ? renderHeaderPartial(p)
|
|
1562
|
-
: name === 'attention' ? renderAttentionPartial(p)
|
|
1563
|
-
: name === 'board' ? renderBoardPartial(p)
|
|
1564
|
-
: renderTotalsPartial(p);
|
|
1565
|
-
ctx.res.statusCode = 200;
|
|
1566
|
-
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1567
|
-
ctx.res.setHeader('cache-control', 'no-store');
|
|
1568
|
-
ctx.res.end(body);
|
|
1569
|
-
}
|
|
1570
|
-
async function handleState(ctx) {
|
|
1571
|
-
if (ctx.method !== 'GET')
|
|
1572
|
-
return methodNotAllowed(ctx.res);
|
|
1573
|
-
jsonResponse(ctx.res, 200, ctx.orch.snapshot());
|
|
1574
|
-
}
|
|
1575
|
-
async function handleRefresh(ctx) {
|
|
1576
|
-
if (ctx.method !== 'POST')
|
|
1577
|
-
return methodNotAllowed(ctx.res);
|
|
1578
|
-
const status = ctx.orch.triggerRefresh();
|
|
1579
|
-
jsonResponse(ctx.res, 202, {
|
|
1580
|
-
...status,
|
|
1581
|
-
requested_at: new Date().toISOString(),
|
|
1582
|
-
operations: ['poll', 'reconcile'],
|
|
1583
|
-
});
|
|
1584
|
-
}
|
|
1585
|
-
async function handleIssues(ctx) {
|
|
1586
|
-
if (ctx.method === 'GET')
|
|
1587
|
-
return handleListIssues(ctx);
|
|
1588
|
-
if (ctx.method === 'POST')
|
|
1589
|
-
return handleCreateIssue(ctx);
|
|
1590
|
-
methodNotAllowed(ctx.res);
|
|
1591
|
-
}
|
|
1592
|
-
async function handleListIssues(ctx) {
|
|
1593
|
-
const root = ctx.view.trackerRoot;
|
|
1594
|
-
if (!root)
|
|
1595
|
-
return jsonResponse(ctx.res, 200, { issues: [] });
|
|
1596
|
-
const issues = await listIssuesFromDisk(root);
|
|
1597
|
-
jsonResponse(ctx.res, 200, { issues });
|
|
1598
|
-
}
|
|
1599
|
-
async function handleCreateIssue(ctx) {
|
|
1600
|
-
const root = ctx.view.trackerRoot;
|
|
1601
|
-
if (!root)
|
|
1602
|
-
return badRequest(ctx.res, 'tracker.root not configured');
|
|
1603
|
-
let body;
|
|
1604
|
-
try {
|
|
1605
|
-
body = await readJsonBody(ctx.req);
|
|
1606
|
-
}
|
|
1607
|
-
catch (err) {
|
|
1608
|
-
return badRequest(ctx.res, err.message);
|
|
1609
|
-
}
|
|
1610
|
-
const decision = decideCreateIssue(body, ctx.view.states);
|
|
1611
|
-
if (!decision.ok) {
|
|
1612
|
-
return jsonResponse(ctx.res, decision.status, {
|
|
1613
|
-
error: { code: decision.code, message: decision.message },
|
|
1614
|
-
});
|
|
1615
|
-
}
|
|
1616
|
-
try {
|
|
1617
|
-
const created = await writeIssueFile({
|
|
1618
|
-
trackerRoot: root,
|
|
1619
|
-
identifier: decision.identifier,
|
|
1620
|
-
title: decision.title,
|
|
1621
|
-
state: decision.state,
|
|
1622
|
-
description: decision.description,
|
|
1623
|
-
priority: decision.priority,
|
|
1624
|
-
labels: decision.labels,
|
|
1625
|
-
blocked_by: decision.blocked_by,
|
|
1626
|
-
});
|
|
1627
|
-
log.info('issue created via http', { identifier: created.identifier, state: created.state });
|
|
1628
|
-
jsonResponse(ctx.res, 201, created);
|
|
1629
|
-
}
|
|
1630
|
-
catch (err) {
|
|
1631
|
-
jsonResponse(ctx.res, 409, {
|
|
1632
|
-
error: { code: 'create_failed', message: err.message },
|
|
1633
|
-
});
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
// MCP JSON-RPC endpoint: agent (inside the smolvm) POSTs JSON-RPC envelopes here. The
|
|
1637
|
-
// URL is per-issue (the agent only knows its own /<id>/mcp), backed by a bearer token
|
|
1638
|
-
// generated at dispatch. Both layers are belt-and-braces against the no-auth 8787 socket.
|
|
1639
|
-
async function handleMcp(ctx) {
|
|
1640
|
-
if (ctx.method !== 'POST')
|
|
1641
|
-
return methodNotAllowed(ctx.res);
|
|
1642
|
-
const mcp = ctx.opts.mcp;
|
|
1643
|
-
if (!mcp)
|
|
1644
|
-
return notFound(ctx.res, 'mcp_disabled', 'mcp endpoint not enabled');
|
|
1645
|
-
const { identifier } = ctx.route;
|
|
1646
|
-
const auth = (ctx.req.headers['authorization'] ?? ctx.req.headers['Authorization']);
|
|
1647
|
-
const token = extractBearerToken(auth);
|
|
1648
|
-
if (!token)
|
|
1649
|
-
return jsonResponse(ctx.res, 401, { error: { code: 'unauthorized', message: 'bearer token required' } });
|
|
1650
|
-
if (!mcp.isActive(identifier, token)) {
|
|
1651
|
-
return jsonResponse(ctx.res, 404, {
|
|
1652
|
-
error: { code: 'not_found', message: 'issue not active or token mismatch' },
|
|
1653
|
-
});
|
|
1654
|
-
}
|
|
1655
|
-
let body;
|
|
1656
|
-
try {
|
|
1657
|
-
body = await readJsonBody(ctx.req);
|
|
1658
|
-
}
|
|
1659
|
-
catch (err) {
|
|
1660
|
-
return badRequest(ctx.res, err.message);
|
|
1661
|
-
}
|
|
1662
|
-
const reply = await mcp.handleJsonRpc(identifier, token, body);
|
|
1663
|
-
if (reply === null) {
|
|
1664
|
-
// JSON-RPC notification (no id) → 204 No Content
|
|
1665
|
-
ctx.res.statusCode = 204;
|
|
1666
|
-
ctx.res.end();
|
|
1667
|
-
return;
|
|
1668
|
-
}
|
|
1669
|
-
jsonResponse(ctx.res, 200, reply);
|
|
1670
|
-
}
|
|
1671
|
-
// Steering reply: the dashboard (or any operator with access) submits the human's
|
|
1672
|
-
// response to a queued request_human_steering call. The orchestrator-side runner is
|
|
1673
|
-
// awaiting on the registry; this POST unblocks it.
|
|
1674
|
-
//
|
|
1675
|
-
// Two callers:
|
|
1676
|
-
// • Dashboard via HTMX (form-encoded body, `HX-Request: true`, same-origin). We accept
|
|
1677
|
-
// only this combination for form bodies and reply with an HTML partial that swaps
|
|
1678
|
-
// into #attention.
|
|
1679
|
-
// • Direct API client (JSON body). Replies with a structured JSON acknowledgement.
|
|
1680
|
-
//
|
|
1681
|
-
// The form-encoded branch is gated on `HX-Request: true` and a same-origin check so a
|
|
1682
|
-
// simple cross-site form POST cannot inject a steering reply: form-encoded is a "simple"
|
|
1683
|
-
// CORS request that bypasses preflight, and the steering endpoint is unauthenticated.
|
|
1684
|
-
// HTMX errors land at 200 OK with an inline `.steering-error` message because HTMX's
|
|
1685
|
-
// default response-handling does not swap on 4xx/5xx; returning 200 keeps the operator's
|
|
1686
|
-
// form in sync with reality (their textarea text is preserved by hx-preserve regardless).
|
|
1687
|
-
async function handleSteeringReply(ctx) {
|
|
1688
|
-
if (ctx.method !== 'POST')
|
|
1689
|
-
return methodNotAllowed(ctx.res);
|
|
1690
|
-
const mcp = ctx.opts.mcp;
|
|
1691
|
-
if (!mcp)
|
|
1692
|
-
return notFound(ctx.res, 'mcp_disabled', 'steering endpoint not enabled');
|
|
1693
|
-
const { identifier } = ctx.route;
|
|
1694
|
-
const isHtmx = ctx.req.headers['hx-request'] === 'true';
|
|
1695
|
-
const ctype = classifyContentType(ctx.req.headers['content-type']);
|
|
1696
|
-
const csrf = checkSteeringCsrf(ctype, isHtmx, isSameOriginRequest(ctx.req));
|
|
1697
|
-
if (!csrf.ok) {
|
|
1698
|
-
return jsonResponse(ctx.res, csrf.status, {
|
|
1699
|
-
error: { code: csrf.code, message: csrf.message },
|
|
1700
|
-
});
|
|
1701
|
-
}
|
|
1702
|
-
const text = await readSteeringText(ctx, ctype);
|
|
1703
|
-
if (text === null)
|
|
1704
|
-
return;
|
|
1705
|
-
if (!text) {
|
|
1706
|
-
return htmxOrJsonError(ctx.res, isHtmx, ctx.orch, ctx.view, 400, 'bad_request', 'text is required and must be a non-empty string');
|
|
1707
|
-
}
|
|
1708
|
-
if (!mcp.submitSteeringReply(identifier, text)) {
|
|
1709
|
-
return htmxOrJsonError(ctx.res, isHtmx, ctx.orch, ctx.view, 409, 'no_pending_steering', 'no agent is awaiting steering for this issue');
|
|
1710
|
-
}
|
|
1711
|
-
await respondSteeringAccepted(ctx, isHtmx, identifier);
|
|
1712
|
-
}
|
|
1713
|
-
async function respondSteeringAccepted(ctx, isHtmx, identifier) {
|
|
1714
|
-
if (isHtmx) {
|
|
1715
|
-
const p = await gatherPartialInputs(ctx.orch, ctx.view);
|
|
1716
|
-
ctx.res.statusCode = 200;
|
|
1717
|
-
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1718
|
-
ctx.res.end(renderAttentionPartial(p));
|
|
1719
|
-
return;
|
|
1720
|
-
}
|
|
1721
|
-
jsonResponse(ctx.res, 202, { identifier, accepted_at: new Date().toISOString() });
|
|
1722
|
-
}
|
|
1723
|
-
// Returns null when the body read errored and a response has already been written;
|
|
1724
|
-
// otherwise returns the extracted text (empty string if absent/blank, caller treats
|
|
1725
|
-
// that as the "no text supplied" case).
|
|
1726
|
-
async function readSteeringText(ctx, ctype) {
|
|
1727
|
-
const isHtmx = ctx.req.headers['hx-request'] === 'true';
|
|
1728
|
-
if (ctype.isFormBody) {
|
|
1729
|
-
try {
|
|
1730
|
-
return extractFormText(await readTextBody(ctx.req));
|
|
1731
|
-
}
|
|
1732
|
-
catch (err) {
|
|
1733
|
-
await htmxOrJsonError(ctx.res, isHtmx, ctx.orch, ctx.view, 400, 'bad_request', err.message);
|
|
1734
|
-
return null;
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
try {
|
|
1738
|
-
return extractJsonText(await readJsonBody(ctx.req));
|
|
1739
|
-
}
|
|
1740
|
-
catch (err) {
|
|
1741
|
-
badRequest(ctx.res, err.message);
|
|
1742
|
-
return null;
|
|
1743
|
-
}
|
|
1744
|
-
}
|
|
1745
|
-
// Triage approve / discard. The dashboard renders these as buttons inside the triage
|
|
1746
|
-
// section; an operator clicks one and we move the issue file out of Triage/. Approve
|
|
1747
|
-
// sends it to the first active state (typically Todo) where the orchestrator will pick
|
|
1748
|
-
// it up on the next poll; discard sends it to the first terminal state that looks like a
|
|
1749
|
-
// cancellation (case-insensitive "Cancelled" match preferred) so the proposal is
|
|
1750
|
-
// archived rather than deleted — operators can still grep for what was proposed and
|
|
1751
|
-
// turned down.
|
|
1752
|
-
//
|
|
1753
|
-
// CSRF posture mirrors the steering-reply endpoint: form-encoded requires HX-Request
|
|
1754
|
-
// + same-origin; JSON requires application/json (preflight-triggering). HTMX errors
|
|
1755
|
-
// come back as 200 with a partial so the section doesn't go stale.
|
|
1756
|
-
async function handleTriage(ctx) {
|
|
1757
|
-
if (ctx.method !== 'POST')
|
|
1758
|
-
return methodNotAllowed(ctx.res);
|
|
1759
|
-
const tracker = ctx.opts.tracker;
|
|
1760
|
-
if (!tracker || !tracker.moveIssueToState) {
|
|
1761
|
-
return notFound(ctx.res, 'tracker_no_state_transitions', 'tracker does not support state transitions');
|
|
1762
|
-
}
|
|
1763
|
-
const root = ctx.view.trackerRoot;
|
|
1764
|
-
if (!root)
|
|
1765
|
-
return badRequest(ctx.res, 'tracker.root not configured');
|
|
1766
|
-
const { identifier, action } = ctx.route;
|
|
1767
|
-
const ctype = classifyContentType(ctx.req.headers['content-type']);
|
|
1768
|
-
const csrf = checkTriageCsrf(ctype, ctx.req.headers['hx-request'] === 'true', isSameOriginRequest(ctx.req));
|
|
1769
|
-
if (!csrf.ok) {
|
|
1770
|
-
return jsonResponse(ctx.res, csrf.status, {
|
|
1771
|
-
error: { code: csrf.code, message: csrf.message },
|
|
1772
|
-
});
|
|
1773
|
-
}
|
|
1774
|
-
const transition = decideTriageTransition(action, ctx.view.states);
|
|
1775
|
-
if (!transition.ok) {
|
|
1776
|
-
return jsonResponse(ctx.res, transition.status, {
|
|
1777
|
-
error: { code: transition.code, message: transition.message },
|
|
1778
|
-
});
|
|
1779
|
-
}
|
|
1780
|
-
await runTriageMove(ctx, tracker, root, identifier, action, transition.toState, transition.fromState);
|
|
1781
|
-
}
|
|
1782
|
-
async function runTriageMove(ctx, tracker, root, identifier, action, toState, fromState) {
|
|
1783
|
-
const isHtmx = ctx.req.headers['hx-request'] === 'true';
|
|
1784
|
-
try {
|
|
1785
|
-
const result = await tracker.moveIssueToState(identifier, toState, { fromRoot: root, fromState });
|
|
1786
|
-
log.info('triage action', { identifier, action, from: result.fromState, to: result.toState });
|
|
1787
|
-
// Nudge the orchestrator to pick the freshly approved issue up immediately instead
|
|
1788
|
-
// of waiting for the next poll tick. Best-effort: triggerRefresh is idempotent.
|
|
1789
|
-
if (action === 'approve') {
|
|
1790
|
-
try {
|
|
1791
|
-
ctx.orch.triggerRefresh();
|
|
1792
|
-
}
|
|
1793
|
-
catch { /* refresh request is fire-and-forget */ }
|
|
1794
|
-
}
|
|
1795
|
-
if (isHtmx)
|
|
1796
|
-
return writeBoardPartial(ctx);
|
|
1797
|
-
jsonResponse(ctx.res, 200, {
|
|
1798
|
-
identifier, action, from_state: result.fromState, to_state: result.toState,
|
|
1799
|
-
});
|
|
1800
|
-
}
|
|
1801
|
-
catch (err) {
|
|
1802
|
-
const code = err.code ?? 'triage_failed';
|
|
1803
|
-
const status = code === 'local_issue_not_found' ? 404 : 409;
|
|
1804
|
-
if (isHtmx)
|
|
1805
|
-
return writeBoardPartial(ctx);
|
|
1806
|
-
jsonResponse(ctx.res, status, { error: { code, message: err.message } });
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
// Re-render the whole board after a triage move. HTMX morph keeps unchanged columns
|
|
1810
|
-
// stable; only the row that moved (or "failed" and stayed) redraws.
|
|
1811
|
-
async function writeBoardPartial(ctx) {
|
|
1812
|
-
const p = await gatherPartialInputs(ctx.orch, ctx.view);
|
|
1813
|
-
ctx.res.statusCode = 200;
|
|
1814
|
-
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1815
|
-
ctx.res.setHeader('cache-control', 'no-store');
|
|
1816
|
-
ctx.res.end(renderBoardPartial(p));
|
|
1817
|
-
}
|
|
1818
|
-
// Read-only HTML view of one issue. Linked from the identifier on every "on disk" and
|
|
1819
|
-
// triage row; renders front-matter (labels, priority, blockers, provenance) plus the
|
|
1820
|
-
// Markdown body so an operator can read the full task without leaving the browser. No
|
|
1821
|
-
// editing surface — actions stay on the dashboard. Source of truth is the on-disk .md
|
|
1822
|
-
// file, found by walking every state directory under tracker.root for a basename match.
|
|
1823
|
-
async function handleDetailHtml(ctx) {
|
|
1824
|
-
if (ctx.method !== 'GET')
|
|
1825
|
-
return methodNotAllowed(ctx.res);
|
|
1826
|
-
const root = ctx.view.trackerRoot;
|
|
1827
|
-
if (!root) {
|
|
1828
|
-
ctx.res.statusCode = 404;
|
|
1829
|
-
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1830
|
-
ctx.res.end(renderIssueNotFoundPage('(tracker root not configured)', ctx.view));
|
|
1831
|
-
return;
|
|
1832
|
-
}
|
|
1833
|
-
const { identifier } = ctx.route;
|
|
1834
|
-
const issue = await readIssueFromDisk(root, identifier);
|
|
1835
|
-
if (!issue) {
|
|
1836
|
-
ctx.res.statusCode = 404;
|
|
1837
|
-
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1838
|
-
ctx.res.end(renderIssueNotFoundPage(identifier, ctx.view));
|
|
1839
|
-
return;
|
|
1840
|
-
}
|
|
1841
|
-
ctx.res.statusCode = 200;
|
|
1842
|
-
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1843
|
-
ctx.res.setHeader('cache-control', 'no-store');
|
|
1844
|
-
ctx.res.end(renderIssueDetailPage(issue, ctx.view));
|
|
1845
|
-
}
|
|
1846
|
-
async function handleDetailJson(ctx) {
|
|
1847
|
-
if (ctx.method !== 'GET')
|
|
1848
|
-
return methodNotAllowed(ctx.res);
|
|
1849
|
-
const { identifier } = ctx.route;
|
|
1850
|
-
const detail = ctx.orch.detailByIdentifier(identifier);
|
|
1851
|
-
if (!detail)
|
|
1852
|
-
return notFound(ctx.res, 'issue_not_found', `issue ${identifier} is not tracked`);
|
|
1853
|
-
jsonResponse(ctx.res, 200, detail);
|
|
1854
|
-
}
|
|
1855
|
-
async function handleNotFoundRoute(ctx) {
|
|
1856
|
-
notFound(ctx.res);
|
|
1857
|
-
}
|
|
1858
|
-
const ROUTE_HANDLERS = {
|
|
1859
|
-
dashboard: handleDashboard,
|
|
1860
|
-
preview: handlePreview,
|
|
1861
|
-
partial: handlePartial,
|
|
1862
|
-
state: handleState,
|
|
1863
|
-
refresh: handleRefresh,
|
|
1864
|
-
issues: handleIssues,
|
|
1865
|
-
mcp: handleMcp,
|
|
1866
|
-
steering: handleSteeringReply,
|
|
1867
|
-
triage: handleTriage,
|
|
1868
|
-
detail_html: handleDetailHtml,
|
|
1869
|
-
detail_json: handleDetailJson,
|
|
1870
|
-
not_found: handleNotFoundRoute,
|
|
1871
|
-
};
|
|
1872
|
-
function renderIssueNotFoundPage(identifier, view) {
|
|
1873
|
-
const workflowName = path.basename(view.workflowPath || 'workflow.md');
|
|
1606
|
+
export function renderIssueNotFoundPage(identifier, view) {
|
|
1607
|
+
const workflowName = basename(view.workflowPath || 'workflow.yaml');
|
|
1874
1608
|
return `<!doctype html>
|
|
1875
1609
|
<html lang="en"><head>
|
|
1876
1610
|
<meta charset="utf-8">
|
|
@@ -1898,4 +1632,4 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; co
|
|
|
1898
1632
|
</main>
|
|
1899
1633
|
</body></html>`;
|
|
1900
1634
|
}
|
|
1901
|
-
//# sourceMappingURL=
|
|
1635
|
+
//# sourceMappingURL=render.js.map
|