@ashlr/hub 2.2.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/CHANGELOG.md +1158 -0
- package/LICENSE +21 -0
- package/README.md +833 -0
- package/bin/ashlr +8 -0
- package/dist/api/core.d.ts +28 -0
- package/dist/api/core.js +42 -0
- package/dist/api/core.js.map +1 -0
- package/dist/api/index.d.ts +9 -0
- package/dist/api/index.js +9 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/plugin.d.ts +12 -0
- package/dist/api/plugin.js +13 -0
- package/dist/api/plugin.js.map +1 -0
- package/dist/api/types.d.ts +9 -0
- package/dist/api/types.js +9 -0
- package/dist/api/types.js.map +1 -0
- package/dist/cli/args.d.ts +20 -0
- package/dist/cli/args.js +23 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/ask.d.ts +22 -0
- package/dist/cli/ask.js +240 -0
- package/dist/cli/ask.js.map +1 -0
- package/dist/cli/audit.d.ts +26 -0
- package/dist/cli/audit.js +199 -0
- package/dist/cli/audit.js.map +1 -0
- package/dist/cli/backlog.d.ts +26 -0
- package/dist/cli/backlog.js +322 -0
- package/dist/cli/backlog.js.map +1 -0
- package/dist/cli/completions.d.ts +18 -0
- package/dist/cli/completions.js +156 -0
- package/dist/cli/completions.js.map +1 -0
- package/dist/cli/daemon.d.ts +31 -0
- package/dist/cli/daemon.js +334 -0
- package/dist/cli/daemon.js.map +1 -0
- package/dist/cli/demo-sandbox.d.ts +78 -0
- package/dist/cli/demo-sandbox.js +227 -0
- package/dist/cli/demo-sandbox.js.map +1 -0
- package/dist/cli/demo.d.ts +76 -0
- package/dist/cli/demo.js +285 -0
- package/dist/cli/demo.js.map +1 -0
- package/dist/cli/digest.d.ts +46 -0
- package/dist/cli/digest.js +352 -0
- package/dist/cli/digest.js.map +1 -0
- package/dist/cli/doctor-init.d.ts +28 -0
- package/dist/cli/doctor-init.js +335 -0
- package/dist/cli/doctor-init.js.map +1 -0
- package/dist/cli/genome.d.ts +62 -0
- package/dist/cli/genome.js +1061 -0
- package/dist/cli/genome.js.map +1 -0
- package/dist/cli/gh.d.ts +27 -0
- package/dist/cli/gh.js +322 -0
- package/dist/cli/gh.js.map +1 -0
- package/dist/cli/goals.d.ts +55 -0
- package/dist/cli/goals.js +667 -0
- package/dist/cli/goals.js.map +1 -0
- package/dist/cli/health.d.ts +56 -0
- package/dist/cli/health.js +407 -0
- package/dist/cli/health.js.map +1 -0
- package/dist/cli/help.d.ts +51 -0
- package/dist/cli/help.js +436 -0
- package/dist/cli/help.js.map +1 -0
- package/dist/cli/inbox.d.ts +28 -0
- package/dist/cli/inbox.js +589 -0
- package/dist/cli/inbox.js.map +1 -0
- package/dist/cli/index.d.ts +41 -0
- package/dist/cli/index.js +1208 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/knowledge.d.ts +33 -0
- package/dist/cli/knowledge.js +575 -0
- package/dist/cli/knowledge.js.map +1 -0
- package/dist/cli/mcp.d.ts +26 -0
- package/dist/cli/mcp.js +487 -0
- package/dist/cli/mcp.js.map +1 -0
- package/dist/cli/models.d.ts +26 -0
- package/dist/cli/models.js +435 -0
- package/dist/cli/models.js.map +1 -0
- package/dist/cli/new.d.ts +32 -0
- package/dist/cli/new.js +565 -0
- package/dist/cli/new.js.map +1 -0
- package/dist/cli/notify.d.ts +20 -0
- package/dist/cli/notify.js +188 -0
- package/dist/cli/notify.js.map +1 -0
- package/dist/cli/onboard.d.ts +85 -0
- package/dist/cli/onboard.js +305 -0
- package/dist/cli/onboard.js.map +1 -0
- package/dist/cli/open.d.ts +38 -0
- package/dist/cli/open.js +86 -0
- package/dist/cli/open.js.map +1 -0
- package/dist/cli/orient.d.ts +14 -0
- package/dist/cli/orient.js +110 -0
- package/dist/cli/orient.js.map +1 -0
- package/dist/cli/picker.d.ts +21 -0
- package/dist/cli/picker.js +177 -0
- package/dist/cli/picker.js.map +1 -0
- package/dist/cli/plugins.d.ts +21 -0
- package/dist/cli/plugins.js +394 -0
- package/dist/cli/plugins.js.map +1 -0
- package/dist/cli/preflight.d.ts +29 -0
- package/dist/cli/preflight.js +131 -0
- package/dist/cli/preflight.js.map +1 -0
- package/dist/cli/pulse.d.ts +26 -0
- package/dist/cli/pulse.js +482 -0
- package/dist/cli/pulse.js.map +1 -0
- package/dist/cli/reflect.d.ts +42 -0
- package/dist/cli/reflect.js +381 -0
- package/dist/cli/reflect.js.map +1 -0
- package/dist/cli/run.d.ts +29 -0
- package/dist/cli/run.js +687 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/cli/sandbox.d.ts +20 -0
- package/dist/cli/sandbox.js +345 -0
- package/dist/cli/sandbox.js.map +1 -0
- package/dist/cli/seams.d.ts +41 -0
- package/dist/cli/seams.js +167 -0
- package/dist/cli/seams.js.map +1 -0
- package/dist/cli/serve.d.ts +25 -0
- package/dist/cli/serve.js +249 -0
- package/dist/cli/serve.js.map +1 -0
- package/dist/cli/ship.d.ts +33 -0
- package/dist/cli/ship.js +433 -0
- package/dist/cli/ship.js.map +1 -0
- package/dist/cli/spec.d.ts +19 -0
- package/dist/cli/spec.js +478 -0
- package/dist/cli/spec.js.map +1 -0
- package/dist/cli/swarm.d.ts +42 -0
- package/dist/cli/swarm.js +1365 -0
- package/dist/cli/swarm.js.map +1 -0
- package/dist/cli/telemetry.d.ts +26 -0
- package/dist/cli/telemetry.js +364 -0
- package/dist/cli/telemetry.js.map +1 -0
- package/dist/cli/tui.d.ts +17 -0
- package/dist/cli/tui.js +52 -0
- package/dist/cli/tui.js.map +1 -0
- package/dist/cli/ui.d.ts +53 -0
- package/dist/cli/ui.js +66 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/cli/update.d.ts +38 -0
- package/dist/cli/update.js +682 -0
- package/dist/cli/update.js.map +1 -0
- package/dist/cli/vercel.d.ts +20 -0
- package/dist/cli/vercel.js +193 -0
- package/dist/cli/vercel.js.map +1 -0
- package/dist/cli/verify-safety.d.ts +96 -0
- package/dist/cli/verify-safety.js +509 -0
- package/dist/cli/verify-safety.js.map +1 -0
- package/dist/cli/wire.d.ts +22 -0
- package/dist/cli/wire.js +205 -0
- package/dist/cli/wire.js.map +1 -0
- package/dist/core/classify.d.ts +51 -0
- package/dist/core/classify.js +450 -0
- package/dist/core/classify.js.map +1 -0
- package/dist/core/config.d.ts +63 -0
- package/dist/core/config.js +474 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/daemon/loop.d.ts +74 -0
- package/dist/core/daemon/loop.js +618 -0
- package/dist/core/daemon/loop.js.map +1 -0
- package/dist/core/daemon/state.d.ts +66 -0
- package/dist/core/daemon/state.js +197 -0
- package/dist/core/daemon/state.js.map +1 -0
- package/dist/core/dashboard.d.ts +40 -0
- package/dist/core/dashboard.js +463 -0
- package/dist/core/dashboard.js.map +1 -0
- package/dist/core/digest/build.d.ts +51 -0
- package/dist/core/digest/build.js +230 -0
- package/dist/core/digest/build.js.map +1 -0
- package/dist/core/digest/deliver.d.ts +47 -0
- package/dist/core/digest/deliver.js +230 -0
- package/dist/core/digest/deliver.js.map +1 -0
- package/dist/core/digest/store.d.ts +57 -0
- package/dist/core/digest/store.js +223 -0
- package/dist/core/digest/store.js.map +1 -0
- package/dist/core/doctor-fix.d.ts +21 -0
- package/dist/core/doctor-fix.js +440 -0
- package/dist/core/doctor-fix.js.map +1 -0
- package/dist/core/doctor.d.ts +18 -0
- package/dist/core/doctor.js +806 -0
- package/dist/core/doctor.js.map +1 -0
- package/dist/core/env-bridge.d.ts +64 -0
- package/dist/core/env-bridge.js +103 -0
- package/dist/core/env-bridge.js.map +1 -0
- package/dist/core/genome/capture.d.ts +49 -0
- package/dist/core/genome/capture.js +352 -0
- package/dist/core/genome/capture.js.map +1 -0
- package/dist/core/genome/consolidate.d.ts +38 -0
- package/dist/core/genome/consolidate.js +426 -0
- package/dist/core/genome/consolidate.js.map +1 -0
- package/dist/core/genome/export.d.ts +29 -0
- package/dist/core/genome/export.js +102 -0
- package/dist/core/genome/export.js.map +1 -0
- package/dist/core/genome/playbook.d.ts +33 -0
- package/dist/core/genome/playbook.js +320 -0
- package/dist/core/genome/playbook.js.map +1 -0
- package/dist/core/genome/recall.d.ts +45 -0
- package/dist/core/genome/recall.js +293 -0
- package/dist/core/genome/recall.js.map +1 -0
- package/dist/core/genome/store.d.ts +62 -0
- package/dist/core/genome/store.js +711 -0
- package/dist/core/genome/store.js.map +1 -0
- package/dist/core/git.d.ts +34 -0
- package/dist/core/git.js +115 -0
- package/dist/core/git.js.map +1 -0
- package/dist/core/goals/advance.d.ts +82 -0
- package/dist/core/goals/advance.js +263 -0
- package/dist/core/goals/advance.js.map +1 -0
- package/dist/core/goals/planner.d.ts +58 -0
- package/dist/core/goals/planner.js +233 -0
- package/dist/core/goals/planner.js.map +1 -0
- package/dist/core/goals/store.d.ts +128 -0
- package/dist/core/goals/store.js +442 -0
- package/dist/core/goals/store.js.map +1 -0
- package/dist/core/inbox/apply.d.ts +36 -0
- package/dist/core/inbox/apply.js +407 -0
- package/dist/core/inbox/apply.js.map +1 -0
- package/dist/core/inbox/notify-proposal.d.ts +13 -0
- package/dist/core/inbox/notify-proposal.js +21 -0
- package/dist/core/inbox/notify-proposal.js.map +1 -0
- package/dist/core/inbox/store.d.ts +67 -0
- package/dist/core/inbox/store.js +258 -0
- package/dist/core/inbox/store.js.map +1 -0
- package/dist/core/index-engine.d.ts +62 -0
- package/dist/core/index-engine.js +486 -0
- package/dist/core/index-engine.js.map +1 -0
- package/dist/core/integrations/desktop-notify.d.ts +18 -0
- package/dist/core/integrations/desktop-notify.js +42 -0
- package/dist/core/integrations/desktop-notify.js.map +1 -0
- package/dist/core/integrations/editors.d.ts +50 -0
- package/dist/core/integrations/editors.js +216 -0
- package/dist/core/integrations/editors.js.map +1 -0
- package/dist/core/integrations/github.d.ts +66 -0
- package/dist/core/integrations/github.js +332 -0
- package/dist/core/integrations/github.js.map +1 -0
- package/dist/core/integrations/identity.d.ts +29 -0
- package/dist/core/integrations/identity.js +359 -0
- package/dist/core/integrations/identity.js.map +1 -0
- package/dist/core/integrations/notify.d.ts +23 -0
- package/dist/core/integrations/notify.js +96 -0
- package/dist/core/integrations/notify.js.map +1 -0
- package/dist/core/integrations/vercel.d.ts +37 -0
- package/dist/core/integrations/vercel.js +192 -0
- package/dist/core/integrations/vercel.js.map +1 -0
- package/dist/core/knowledge/ask.d.ts +34 -0
- package/dist/core/knowledge/ask.js +325 -0
- package/dist/core/knowledge/ask.js.map +1 -0
- package/dist/core/knowledge/graph.d.ts +51 -0
- package/dist/core/knowledge/graph.js +564 -0
- package/dist/core/knowledge/graph.js.map +1 -0
- package/dist/core/knowledge/index.d.ts +74 -0
- package/dist/core/knowledge/index.js +586 -0
- package/dist/core/knowledge/index.js.map +1 -0
- package/dist/core/learn/playbooks.d.ts +84 -0
- package/dist/core/learn/playbooks.js +241 -0
- package/dist/core/learn/playbooks.js.map +1 -0
- package/dist/core/learn/reflect.d.ts +87 -0
- package/dist/core/learn/reflect.js +435 -0
- package/dist/core/learn/reflect.js.map +1 -0
- package/dist/core/learn/store.d.ts +51 -0
- package/dist/core/learn/store.js +165 -0
- package/dist/core/learn/store.js.map +1 -0
- package/dist/core/learn/tuning.d.ts +48 -0
- package/dist/core/learn/tuning.js +201 -0
- package/dist/core/learn/tuning.js.map +1 -0
- package/dist/core/lifecycle/scaffold.d.ts +43 -0
- package/dist/core/lifecycle/scaffold.js +260 -0
- package/dist/core/lifecycle/scaffold.js.map +1 -0
- package/dist/core/lifecycle/ship.d.ts +48 -0
- package/dist/core/lifecycle/ship.js +513 -0
- package/dist/core/lifecycle/ship.js.map +1 -0
- package/dist/core/lifecycle/templates.d.ts +20 -0
- package/dist/core/lifecycle/templates.js +605 -0
- package/dist/core/lifecycle/templates.js.map +1 -0
- package/dist/core/mcp-gateway.d.ts +59 -0
- package/dist/core/mcp-gateway.js +385 -0
- package/dist/core/mcp-gateway.js.map +1 -0
- package/dist/core/mcp-native.d.ts +53 -0
- package/dist/core/mcp-native.js +507 -0
- package/dist/core/mcp-native.js.map +1 -0
- package/dist/core/mcp-registry.d.ts +36 -0
- package/dist/core/mcp-registry.js +180 -0
- package/dist/core/mcp-registry.js.map +1 -0
- package/dist/core/observability/budget-alert.d.ts +18 -0
- package/dist/core/observability/budget-alert.js +90 -0
- package/dist/core/observability/budget-alert.js.map +1 -0
- package/dist/core/observability/estimate.d.ts +27 -0
- package/dist/core/observability/estimate.js +188 -0
- package/dist/core/observability/estimate.js.map +1 -0
- package/dist/core/observability/forecast.d.ts +19 -0
- package/dist/core/observability/forecast.js +101 -0
- package/dist/core/observability/forecast.js.map +1 -0
- package/dist/core/observability/governance.d.ts +28 -0
- package/dist/core/observability/governance.js +101 -0
- package/dist/core/observability/governance.js.map +1 -0
- package/dist/core/observability/otlp.d.ts +88 -0
- package/dist/core/observability/otlp.js +217 -0
- package/dist/core/observability/otlp.js.map +1 -0
- package/dist/core/observability/rollup.d.ts +35 -0
- package/dist/core/observability/rollup.js +311 -0
- package/dist/core/observability/rollup.js.map +1 -0
- package/dist/core/observability/telemetry-sink.d.ts +63 -0
- package/dist/core/observability/telemetry-sink.js +350 -0
- package/dist/core/observability/telemetry-sink.js.map +1 -0
- package/dist/core/observability/usage-source.d.ts +60 -0
- package/dist/core/observability/usage-source.js +347 -0
- package/dist/core/observability/usage-source.js.map +1 -0
- package/dist/core/onboard.d.ts +27 -0
- package/dist/core/onboard.js +288 -0
- package/dist/core/onboard.js.map +1 -0
- package/dist/core/orient.d.ts +23 -0
- package/dist/core/orient.js +135 -0
- package/dist/core/orient.js.map +1 -0
- package/dist/core/phantom.d.ts +23 -0
- package/dist/core/phantom.js +279 -0
- package/dist/core/phantom.js.map +1 -0
- package/dist/core/plugins/host-api.d.ts +29 -0
- package/dist/core/plugins/host-api.js +113 -0
- package/dist/core/plugins/host-api.js.map +1 -0
- package/dist/core/plugins/integrity.d.ts +36 -0
- package/dist/core/plugins/integrity.js +73 -0
- package/dist/core/plugins/integrity.js.map +1 -0
- package/dist/core/plugins/manifest.d.ts +31 -0
- package/dist/core/plugins/manifest.js +316 -0
- package/dist/core/plugins/manifest.js.map +1 -0
- package/dist/core/plugins/registry.d.ts +87 -0
- package/dist/core/plugins/registry.js +415 -0
- package/dist/core/plugins/registry.js.map +1 -0
- package/dist/core/plugins/types.d.ts +182 -0
- package/dist/core/plugins/types.js +40 -0
- package/dist/core/plugins/types.js.map +1 -0
- package/dist/core/plugins/wrappers.d.ts +40 -0
- package/dist/core/plugins/wrappers.js +229 -0
- package/dist/core/plugins/wrappers.js.map +1 -0
- package/dist/core/portfolio/backlog.d.ts +40 -0
- package/dist/core/portfolio/backlog.js +177 -0
- package/dist/core/portfolio/backlog.js.map +1 -0
- package/dist/core/portfolio/scanners.d.ts +21 -0
- package/dist/core/portfolio/scanners.js +600 -0
- package/dist/core/portfolio/scanners.js.map +1 -0
- package/dist/core/providers.d.ts +34 -0
- package/dist/core/providers.js +250 -0
- package/dist/core/providers.js.map +1 -0
- package/dist/core/quality/conventions.d.ts +35 -0
- package/dist/core/quality/conventions.js +267 -0
- package/dist/core/quality/conventions.js.map +1 -0
- package/dist/core/quality/fixes.d.ts +56 -0
- package/dist/core/quality/fixes.js +209 -0
- package/dist/core/quality/fixes.js.map +1 -0
- package/dist/core/quality/health.d.ts +69 -0
- package/dist/core/quality/health.js +350 -0
- package/dist/core/quality/health.js.map +1 -0
- package/dist/core/quality/store.d.ts +56 -0
- package/dist/core/quality/store.js +195 -0
- package/dist/core/quality/store.js.map +1 -0
- package/dist/core/readiness.d.ts +112 -0
- package/dist/core/readiness.js +431 -0
- package/dist/core/readiness.js.map +1 -0
- package/dist/core/run/agent-loop.d.ts +40 -0
- package/dist/core/run/agent-loop.js +291 -0
- package/dist/core/run/agent-loop.js.map +1 -0
- package/dist/core/run/budget.d.ts +47 -0
- package/dist/core/run/budget.js +113 -0
- package/dist/core/run/budget.js.map +1 -0
- package/dist/core/run/engines.d.ts +75 -0
- package/dist/core/run/engines.js +199 -0
- package/dist/core/run/engines.js.map +1 -0
- package/dist/core/run/model-manager.d.ts +64 -0
- package/dist/core/run/model-manager.js +339 -0
- package/dist/core/run/model-manager.js.map +1 -0
- package/dist/core/run/orchestrator.d.ts +100 -0
- package/dist/core/run/orchestrator.js +1515 -0
- package/dist/core/run/orchestrator.js.map +1 -0
- package/dist/core/run/provider-client.d.ts +46 -0
- package/dist/core/run/provider-client.js +796 -0
- package/dist/core/run/provider-client.js.map +1 -0
- package/dist/core/run/retry.d.ts +19 -0
- package/dist/core/run/retry.js +68 -0
- package/dist/core/run/retry.js.map +1 -0
- package/dist/core/run/router.d.ts +50 -0
- package/dist/core/run/router.js +257 -0
- package/dist/core/run/router.js.map +1 -0
- package/dist/core/run/self-heal.d.ts +52 -0
- package/dist/core/run/self-heal.js +181 -0
- package/dist/core/run/self-heal.js.map +1 -0
- package/dist/core/run/streaming.d.ts +31 -0
- package/dist/core/run/streaming.js +122 -0
- package/dist/core/run/streaming.js.map +1 -0
- package/dist/core/run/verify.d.ts +30 -0
- package/dist/core/run/verify.js +204 -0
- package/dist/core/run/verify.js.map +1 -0
- package/dist/core/sandbox/audit.d.ts +31 -0
- package/dist/core/sandbox/audit.js +169 -0
- package/dist/core/sandbox/audit.js.map +1 -0
- package/dist/core/sandbox/policy.d.ts +61 -0
- package/dist/core/sandbox/policy.js +211 -0
- package/dist/core/sandbox/policy.js.map +1 -0
- package/dist/core/sandbox/worktree.d.ts +175 -0
- package/dist/core/sandbox/worktree.js +673 -0
- package/dist/core/sandbox/worktree.js.map +1 -0
- package/dist/core/seams/backlog.d.ts +47 -0
- package/dist/core/seams/backlog.js +47 -0
- package/dist/core/seams/backlog.js.map +1 -0
- package/dist/core/seams/daemon-coordinator.d.ts +72 -0
- package/dist/core/seams/daemon-coordinator.js +76 -0
- package/dist/core/seams/daemon-coordinator.js.map +1 -0
- package/dist/core/seams/genome.d.ts +45 -0
- package/dist/core/seams/genome.js +53 -0
- package/dist/core/seams/genome.js.map +1 -0
- package/dist/core/seams/identity.d.ts +40 -0
- package/dist/core/seams/identity.js +44 -0
- package/dist/core/seams/identity.js.map +1 -0
- package/dist/core/seams/inbox.d.ts +60 -0
- package/dist/core/seams/inbox.js +66 -0
- package/dist/core/seams/inbox.js.map +1 -0
- package/dist/core/seams/index.d.ts +20 -0
- package/dist/core/seams/index.js +21 -0
- package/dist/core/seams/index.js.map +1 -0
- package/dist/core/seams/portfolio.d.ts +50 -0
- package/dist/core/seams/portfolio.js +61 -0
- package/dist/core/seams/portfolio.js.map +1 -0
- package/dist/core/seams/registry.d.ts +42 -0
- package/dist/core/seams/registry.js +128 -0
- package/dist/core/seams/registry.js.map +1 -0
- package/dist/core/seams/run-swarm.d.ts +66 -0
- package/dist/core/seams/run-swarm.js +74 -0
- package/dist/core/seams/run-swarm.js.map +1 -0
- package/dist/core/seams/types.d.ts +123 -0
- package/dist/core/seams/types.js +35 -0
- package/dist/core/seams/types.js.map +1 -0
- package/dist/core/spec/spec-store.d.ts +62 -0
- package/dist/core/spec/spec-store.js +359 -0
- package/dist/core/spec/spec-store.js.map +1 -0
- package/dist/core/swarm/gate.d.ts +45 -0
- package/dist/core/swarm/gate.js +114 -0
- package/dist/core/swarm/gate.js.map +1 -0
- package/dist/core/swarm/planner.d.ts +32 -0
- package/dist/core/swarm/planner.js +293 -0
- package/dist/core/swarm/planner.js.map +1 -0
- package/dist/core/swarm/rollback.d.ts +56 -0
- package/dist/core/swarm/rollback.js +266 -0
- package/dist/core/swarm/rollback.js.map +1 -0
- package/dist/core/swarm/runner.d.ts +62 -0
- package/dist/core/swarm/runner.js +1263 -0
- package/dist/core/swarm/runner.js.map +1 -0
- package/dist/core/swarm/sign.d.ts +71 -0
- package/dist/core/swarm/sign.js +362 -0
- package/dist/core/swarm/sign.js.map +1 -0
- package/dist/core/swarm/store.d.ts +52 -0
- package/dist/core/swarm/store.js +195 -0
- package/dist/core/swarm/store.js.map +1 -0
- package/dist/core/tidy.d.ts +32 -0
- package/dist/core/tidy.js +354 -0
- package/dist/core/tidy.js.map +1 -0
- package/dist/core/tools-registry.d.ts +16 -0
- package/dist/core/tools-registry.js +308 -0
- package/dist/core/tools-registry.js.map +1 -0
- package/dist/core/types.d.ts +2545 -0
- package/dist/core/types.js +9 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/web/api.d.ts +53 -0
- package/dist/core/web/api.js +698 -0
- package/dist/core/web/api.js.map +1 -0
- package/dist/core/web/public/app.js +1906 -0
- package/dist/core/web/public/index.html +721 -0
- package/dist/core/web/public/styles.css +2007 -0
- package/dist/core/web/server.d.ts +18 -0
- package/dist/core/web/server.js +122 -0
- package/dist/core/web/server.js.map +1 -0
- package/dist/core/web/static.d.ts +18 -0
- package/dist/core/web/static.js +122 -0
- package/dist/core/web/static.js.map +1 -0
- package/dist/tui/app.d.ts +33 -0
- package/dist/tui/app.js +350 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/render.d.ts +20 -0
- package/dist/tui/render.js +558 -0
- package/dist/tui/render.js.map +1 -0
- package/package.json +80 -0
- package/schema/config.schema.json +223 -0
|
@@ -0,0 +1,1906 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* app.js — ashlr-hub local web dashboard SPA
|
|
3
|
+
*
|
|
4
|
+
* Vanilla JS, no framework, no CDN, no external deps.
|
|
5
|
+
* Runs entirely in the browser against the local API served by startServer().
|
|
6
|
+
*
|
|
7
|
+
* Views (hash-routed):
|
|
8
|
+
* #overview — DashboardSnapshot cards
|
|
9
|
+
* #runs — RunState table + dispatch panel (M32)
|
|
10
|
+
* #swarms — SwarmRun list + DAG graph + live burndown
|
|
11
|
+
* #pulse — ActivityRollup SVG bar charts
|
|
12
|
+
* #genome — GenomeEntry search + list
|
|
13
|
+
* #portfolio — PortfolioSummary org-level view (read-only; M29)
|
|
14
|
+
* #inbox — Pending proposals list + approve/reject (M32)
|
|
15
|
+
* #daemon — Daemon state card (M32)
|
|
16
|
+
*
|
|
17
|
+
* Live updates via EventSource('/api/events') — patches runs + swarms +
|
|
18
|
+
* inbox + daemon views.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Constants
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const VIEWS = ['overview', 'runs', 'swarms', 'pulse', 'genome', 'portfolio', 'inbox', 'daemon'];
|
|
28
|
+
const DEFAULT_VIEW = 'overview';
|
|
29
|
+
const API_BASE = ''; // same origin
|
|
30
|
+
|
|
31
|
+
// sessionStorage key for the session token (never localStorage, never URL).
|
|
32
|
+
const TOKEN_STORAGE_KEY = 'ashlr-token';
|
|
33
|
+
|
|
34
|
+
// Status -> color map (matches brand palette from styles.css)
|
|
35
|
+
const STATUS_COLOR = {
|
|
36
|
+
// Run / Swarm lifecycle
|
|
37
|
+
running: '#4ade80', // green
|
|
38
|
+
done: '#60a5fa', // blue
|
|
39
|
+
failed: '#f87171', // red
|
|
40
|
+
aborted: '#fbbf24', // amber
|
|
41
|
+
planning: '#a78bfa', // purple
|
|
42
|
+
// Task-level
|
|
43
|
+
pending: '#6b7280', // gray
|
|
44
|
+
skipped: '#9ca3af', // light gray
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Swarm phase -> column index for DAG layout
|
|
48
|
+
const PHASE_ORDER = { scaffold: 0, build: 1, integrate: 2, verify: 3, review: 4 };
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Tiny reactive state
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const state = {
|
|
55
|
+
snapshot: null,
|
|
56
|
+
runs: [],
|
|
57
|
+
swarms: [],
|
|
58
|
+
currentSwarm: null,
|
|
59
|
+
pulse: null,
|
|
60
|
+
genome: [],
|
|
61
|
+
genomeQuery: '',
|
|
62
|
+
portfolio: null, // M29: read-only org-level PortfolioSummary | null
|
|
63
|
+
// M32: inbox + daemon
|
|
64
|
+
inbox: { pending: 0, proposals: [] },
|
|
65
|
+
inboxDetail: null, // currently-open Proposal (full, with diff)
|
|
66
|
+
daemon: null, // DaemonState | null
|
|
67
|
+
inboxBadge: 0, // pending count from SSE, drives nav badge
|
|
68
|
+
loading: {}, // viewName -> boolean
|
|
69
|
+
error: {}, // viewName -> string | null
|
|
70
|
+
activeView: DEFAULT_VIEW,
|
|
71
|
+
eventSource: null,
|
|
72
|
+
// M32: estimate debounce timer
|
|
73
|
+
_estimateTimer: null,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Utility helpers
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
function el(tag, attrs = {}, ...children) {
|
|
81
|
+
const e = document.createElement(tag);
|
|
82
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
83
|
+
if (k === 'cls') { e.className = v; }
|
|
84
|
+
else if (k.startsWith('on')) { e.addEventListener(k.slice(2).toLowerCase(), v); }
|
|
85
|
+
else { e.setAttribute(k, v); }
|
|
86
|
+
}
|
|
87
|
+
for (const c of children) {
|
|
88
|
+
if (c == null) continue;
|
|
89
|
+
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
90
|
+
}
|
|
91
|
+
return e;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function svgEl(tag, attrs = {}, ...children) {
|
|
95
|
+
const e = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
|
96
|
+
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
|
|
97
|
+
for (const c of children) {
|
|
98
|
+
if (c == null) continue;
|
|
99
|
+
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
100
|
+
}
|
|
101
|
+
return e;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fmt(n, decimals = 2) {
|
|
105
|
+
if (typeof n !== 'number' || isNaN(n)) return '—';
|
|
106
|
+
return n.toFixed(decimals);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function fmtK(n) {
|
|
110
|
+
if (typeof n !== 'number') return '—';
|
|
111
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
112
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
|
113
|
+
return String(n);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fmtDate(iso) {
|
|
117
|
+
if (!iso) return '—';
|
|
118
|
+
try {
|
|
119
|
+
const d = new Date(iso);
|
|
120
|
+
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
121
|
+
} catch { return iso; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function fmtRelative(iso) {
|
|
125
|
+
if (!iso) return '—';
|
|
126
|
+
try {
|
|
127
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
128
|
+
const mins = Math.floor(diff / 60_000);
|
|
129
|
+
if (mins < 1) return 'just now';
|
|
130
|
+
if (mins < 60) return `${mins}m ago`;
|
|
131
|
+
const hrs = Math.floor(mins / 60);
|
|
132
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
133
|
+
return `${Math.floor(hrs / 24)}d ago`;
|
|
134
|
+
} catch { return iso; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function statusBadge(status) {
|
|
138
|
+
const color = STATUS_COLOR[status] ?? '#9ca3af';
|
|
139
|
+
return el('span', { cls: 'badge', style: `background:${color}20;color:${color};border:1px solid ${color}40` }, status ?? '—');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function progressBar(done, total) {
|
|
143
|
+
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
144
|
+
const wrap = el('div', { cls: 'progress-track' });
|
|
145
|
+
const fill = el('div', { cls: 'progress-fill', style: `width:${pct}%` });
|
|
146
|
+
wrap.appendChild(fill);
|
|
147
|
+
return el('div', { cls: 'progress-wrap' },
|
|
148
|
+
wrap,
|
|
149
|
+
el('span', { cls: 'progress-label' }, `${done}/${total}`)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// API fetching
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
async function apiFetch(path) {
|
|
158
|
+
const res = await fetch(API_BASE + path);
|
|
159
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
160
|
+
return res.json();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Token-authenticated POST. Returns parsed JSON or throws with message.
|
|
164
|
+
async function apiPost(path, token) {
|
|
165
|
+
const res = await fetch(API_BASE + path, {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
headers: {
|
|
168
|
+
'Content-Type': 'application/json',
|
|
169
|
+
'x-ashlr-token': token,
|
|
170
|
+
},
|
|
171
|
+
body: '{}',
|
|
172
|
+
});
|
|
173
|
+
const json = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
|
|
174
|
+
if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);
|
|
175
|
+
return json;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Token management (sessionStorage only — never localStorage, never URL)
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
function getToken() {
|
|
183
|
+
try { return sessionStorage.getItem(TOKEN_STORAGE_KEY) ?? ''; } catch { return ''; }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function setToken(t) {
|
|
187
|
+
try { sessionStorage.setItem(TOKEN_STORAGE_KEY, t); } catch {}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function clearToken() {
|
|
191
|
+
try { sessionStorage.removeItem(TOKEN_STORAGE_KEY); } catch {}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Prompt user for token and store it; returns the entered value (may be empty).
|
|
195
|
+
function promptToken() {
|
|
196
|
+
const current = getToken();
|
|
197
|
+
const entered = window.prompt(
|
|
198
|
+
'Enter the ashlr session token printed by the server at startup.\n' +
|
|
199
|
+
'It is stored in sessionStorage only (cleared when the tab closes).\n\n' +
|
|
200
|
+
(current ? 'Current token is set. Leave blank to clear it.' : 'No token is set yet.'),
|
|
201
|
+
current
|
|
202
|
+
);
|
|
203
|
+
if (entered === null) return current; // cancelled
|
|
204
|
+
const trimmed = entered.trim();
|
|
205
|
+
if (trimmed) { setToken(trimmed); } else { clearToken(); }
|
|
206
|
+
updateTokenIndicator();
|
|
207
|
+
return trimmed;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Update the subtle token indicator in the nav bar.
|
|
211
|
+
function updateTokenIndicator() {
|
|
212
|
+
const el_ = document.getElementById('token-indicator');
|
|
213
|
+
if (!el_) return;
|
|
214
|
+
const hasToken = Boolean(getToken());
|
|
215
|
+
el_.title = hasToken ? 'Session token is set (click to change)' : 'No session token — click to enter';
|
|
216
|
+
el_.classList.toggle('token-indicator--set', hasToken);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// HTML escaping (for untrusted diff text rendered in <pre>)
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
function escapeHtml(s) {
|
|
224
|
+
if (typeof s !== 'string') return '';
|
|
225
|
+
return s
|
|
226
|
+
.replace(/&/g, '&')
|
|
227
|
+
.replace(/</g, '<')
|
|
228
|
+
.replace(/>/g, '>')
|
|
229
|
+
.replace(/"/g, '"')
|
|
230
|
+
.replace(/'/g, ''');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// EventSource — live updates
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
function connectSSE() {
|
|
238
|
+
if (state.eventSource) return;
|
|
239
|
+
try {
|
|
240
|
+
const es = new EventSource('/api/events');
|
|
241
|
+
state.eventSource = es;
|
|
242
|
+
|
|
243
|
+
es.addEventListener('snapshot', (e) => {
|
|
244
|
+
try { state.snapshot = JSON.parse(e.data); renderActiveView(); } catch {}
|
|
245
|
+
});
|
|
246
|
+
es.addEventListener('runs', (e) => {
|
|
247
|
+
try { state.runs = JSON.parse(e.data); if (state.activeView === 'runs') renderRuns(); } catch {}
|
|
248
|
+
});
|
|
249
|
+
es.addEventListener('swarms', (e) => {
|
|
250
|
+
try {
|
|
251
|
+
state.swarms = JSON.parse(e.data);
|
|
252
|
+
if (state.activeView === 'swarms') renderSwarms();
|
|
253
|
+
// Also refresh overview swarm cards
|
|
254
|
+
if (state.activeView === 'overview' && state.snapshot) renderOverview();
|
|
255
|
+
} catch {}
|
|
256
|
+
});
|
|
257
|
+
// M32: live inbox state — update badge and, if viewing inbox, re-render
|
|
258
|
+
es.addEventListener('inbox', (e) => {
|
|
259
|
+
try {
|
|
260
|
+
const data = JSON.parse(e.data);
|
|
261
|
+
state.inbox = data;
|
|
262
|
+
state.inboxBadge = data.pending ?? 0;
|
|
263
|
+
updateInboxBadge();
|
|
264
|
+
if (state.activeView === 'inbox') renderInbox();
|
|
265
|
+
} catch {}
|
|
266
|
+
});
|
|
267
|
+
// M32: live daemon state
|
|
268
|
+
es.addEventListener('daemon', (e) => {
|
|
269
|
+
try {
|
|
270
|
+
state.daemon = JSON.parse(e.data);
|
|
271
|
+
if (state.activeView === 'daemon') renderDaemon();
|
|
272
|
+
} catch {}
|
|
273
|
+
});
|
|
274
|
+
es.addEventListener('error', () => {
|
|
275
|
+
// Silently tolerate — browser will auto-reconnect or server is stopping
|
|
276
|
+
});
|
|
277
|
+
} catch {
|
|
278
|
+
// EventSource not available or server not yet up — silent
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Update the pending-count badge on the Inbox nav link.
|
|
283
|
+
function updateInboxBadge() {
|
|
284
|
+
const badge = document.getElementById('inbox-nav-badge');
|
|
285
|
+
if (!badge) return;
|
|
286
|
+
const n = state.inboxBadge;
|
|
287
|
+
badge.textContent = n > 0 ? String(n > 99 ? '99+' : n) : '';
|
|
288
|
+
badge.hidden = n === 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function disconnectSSE() {
|
|
292
|
+
if (state.eventSource) {
|
|
293
|
+
state.eventSource.close();
|
|
294
|
+
state.eventSource = null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Close the live stream cleanly when the page is unloaded so the server's
|
|
299
|
+
// SSE connection (and its poll timer) is released promptly.
|
|
300
|
+
window.addEventListener('beforeunload', disconnectSSE);
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Router
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
function getView() {
|
|
307
|
+
const h = location.hash.replace('#', '').trim();
|
|
308
|
+
return VIEWS.includes(h) ? h : DEFAULT_VIEW;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function navigate(view) {
|
|
312
|
+
location.hash = view;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function onHashChange() {
|
|
316
|
+
const view = getView();
|
|
317
|
+
state.activeView = view;
|
|
318
|
+
setActiveNav(view);
|
|
319
|
+
renderActiveView();
|
|
320
|
+
await loadView(view);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function renderActiveView() {
|
|
324
|
+
const view = state.activeView;
|
|
325
|
+
if (view === 'overview') renderOverview();
|
|
326
|
+
else if (view === 'runs') renderRuns();
|
|
327
|
+
else if (view === 'swarms') renderSwarms();
|
|
328
|
+
else if (view === 'pulse') renderPulse();
|
|
329
|
+
else if (view === 'genome') renderGenome();
|
|
330
|
+
else if (view === 'portfolio') renderPortfolio();
|
|
331
|
+
else if (view === 'inbox') renderInbox();
|
|
332
|
+
else if (view === 'daemon') renderDaemon();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function loadView(view) {
|
|
336
|
+
if (view === 'overview') await loadOverview();
|
|
337
|
+
else if (view === 'runs') await loadRuns();
|
|
338
|
+
else if (view === 'swarms') await loadSwarms();
|
|
339
|
+
else if (view === 'pulse') await loadPulse();
|
|
340
|
+
else if (view === 'genome') await loadGenome();
|
|
341
|
+
else if (view === 'portfolio') await loadPortfolio();
|
|
342
|
+
else if (view === 'inbox') await loadInbox();
|
|
343
|
+
else if (view === 'daemon') await loadDaemon();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Nav
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
function setActiveNav(view) {
|
|
351
|
+
document.querySelectorAll('.nav-link').forEach((a) => {
|
|
352
|
+
a.classList.toggle('active', a.dataset.view === view);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// Shell: render the skeleton (called once on DOMContentLoaded)
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
function renderShell() {
|
|
361
|
+
document.body.innerHTML = '';
|
|
362
|
+
|
|
363
|
+
// Nav icons per view (inline SVG path data)
|
|
364
|
+
const VIEW_ICONS = {
|
|
365
|
+
overview: '<rect x="1" y="1" width="6" height="6" rx="1" fill="currentColor" opacity=".85"/><rect x="9" y="1" width="6" height="6" rx="1" fill="currentColor" opacity=".55"/><rect x="1" y="9" width="6" height="6" rx="1" fill="currentColor" opacity=".55"/><rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" opacity=".3"/>',
|
|
366
|
+
runs: '<circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.5"/><polyline points="5.5,8 7.5,10 10.5,6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
|
367
|
+
swarms: '<circle cx="8" cy="3" r="2" fill="currentColor" opacity=".9"/><circle cx="3" cy="13" r="2" fill="currentColor" opacity=".7"/><circle cx="13" cy="13" r="2" fill="currentColor" opacity=".7"/><line x1="8" y1="5" x2="3" y2="11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="8" y1="5" x2="13" y2="11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="3" y1="13" x2="13" y2="13" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>',
|
|
368
|
+
pulse: '<polyline points="1,8 4,8 5.5,3 7,13 8.5,6 10,10 11.5,8 15,8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
|
369
|
+
genome: '<path d="M5,1 Q8,4 5,7 Q8,10 5,13" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" fill="none"/><path d="M11,1 Q8,4 11,7 Q8,10 11,13" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" fill="none"/><line x1="6.5" y1="3.5" x2="9.5" y2="3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="6" y1="7" x2="10" y2="7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="6.5" y1="10.5" x2="9.5" y2="10.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>',
|
|
370
|
+
portfolio: '<rect x="1.5" y="9" width="3" height="5.5" rx="0.6" stroke="currentColor" stroke-width="1.2" fill="none"/><rect x="6.5" y="5" width="3" height="9.5" rx="0.6" stroke="currentColor" stroke-width="1.2" fill="none"/><rect x="11.5" y="2" width="3" height="12.5" rx="0.6" stroke="currentColor" stroke-width="1.2" fill="none"/>',
|
|
371
|
+
inbox: '<rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="currentColor" stroke-width="1.4" fill="none"/><polyline points="1.5,5 8,9.5 14.5,5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
|
372
|
+
daemon: '<circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.5" fill="none"/><line x1="8" y1="4.5" x2="8" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="8" y1="8" x2="11" y2="10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>',
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// Build nav links, including inbox badge
|
|
376
|
+
const navLinks = VIEWS.map((v) => {
|
|
377
|
+
const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
378
|
+
iconSvg.setAttribute('width', '14');
|
|
379
|
+
iconSvg.setAttribute('height', '14');
|
|
380
|
+
iconSvg.setAttribute('viewBox', '0 0 16 16');
|
|
381
|
+
iconSvg.setAttribute('fill', 'none');
|
|
382
|
+
iconSvg.setAttribute('aria-hidden', 'true');
|
|
383
|
+
iconSvg.innerHTML = VIEW_ICONS[v] ?? '';
|
|
384
|
+
|
|
385
|
+
const label = document.createTextNode(v.charAt(0).toUpperCase() + v.slice(1));
|
|
386
|
+
const a = el('a', { cls: 'nav-link', href: `#${v}`, 'data-view': v });
|
|
387
|
+
a.appendChild(iconSvg);
|
|
388
|
+
a.appendChild(label);
|
|
389
|
+
|
|
390
|
+
// Inbox pending badge
|
|
391
|
+
if (v === 'inbox') {
|
|
392
|
+
const badge = el('span', { cls: 'nav-inbox-badge', id: 'inbox-nav-badge', 'aria-label': 'pending proposals' });
|
|
393
|
+
badge.hidden = true;
|
|
394
|
+
a.appendChild(badge);
|
|
395
|
+
}
|
|
396
|
+
return a;
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Token button (gear icon in nav footer)
|
|
400
|
+
const tokenBtn = el('button', {
|
|
401
|
+
cls: 'token-btn',
|
|
402
|
+
id: 'token-indicator',
|
|
403
|
+
title: 'No session token — click to enter',
|
|
404
|
+
type: 'button',
|
|
405
|
+
onClick: () => { promptToken(); },
|
|
406
|
+
});
|
|
407
|
+
// gear SVG
|
|
408
|
+
const gearSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
409
|
+
gearSvg.setAttribute('width', '14'); gearSvg.setAttribute('height', '14');
|
|
410
|
+
gearSvg.setAttribute('viewBox', '0 0 16 16'); gearSvg.setAttribute('fill', 'none');
|
|
411
|
+
gearSvg.setAttribute('aria-hidden', 'true');
|
|
412
|
+
gearSvg.innerHTML = '<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.3" fill="none"/><path d="M8 1.5V3M8 13v1.5M1.5 8H3M13 8h1.5M3.22 3.22l1.06 1.06M11.72 11.72l1.06 1.06M12.78 3.22l-1.06 1.06M4.28 11.72l-1.06 1.06" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>';
|
|
413
|
+
tokenBtn.appendChild(gearSvg);
|
|
414
|
+
tokenBtn.appendChild(document.createTextNode(' Token'));
|
|
415
|
+
|
|
416
|
+
// Top nav
|
|
417
|
+
const nav = el('nav', { cls: 'topnav' },
|
|
418
|
+
el('div', { cls: 'nav-brand' },
|
|
419
|
+
el('span', { cls: 'brand-icon' }, '⬡'),
|
|
420
|
+
el('span', { cls: 'brand-name' }, 'ashlr hub')
|
|
421
|
+
),
|
|
422
|
+
el('div', { cls: 'nav-links' }, ...navLinks),
|
|
423
|
+
el('div', { cls: 'nav-status' },
|
|
424
|
+
el('span', { cls: 'sse-dot', id: 'sse-dot', title: 'Live stream' }),
|
|
425
|
+
tokenBtn
|
|
426
|
+
)
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const main = el('main', { cls: 'main', id: 'main' });
|
|
430
|
+
document.body.appendChild(nav);
|
|
431
|
+
document.body.appendChild(main);
|
|
432
|
+
updateTokenIndicator();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function getMain() {
|
|
436
|
+
return document.getElementById('main');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Loading / error helpers
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
function showLoading(view) {
|
|
444
|
+
if (state.activeView !== view) return;
|
|
445
|
+
const main = getMain();
|
|
446
|
+
if (main) main.innerHTML = '<div class="loading-wrap"><div class="spinner"></div><p>Loading…</p></div>';
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function showError(view, msg) {
|
|
450
|
+
if (state.activeView !== view) return;
|
|
451
|
+
const main = getMain();
|
|
452
|
+
if (main) {
|
|
453
|
+
main.innerHTML = '';
|
|
454
|
+
main.appendChild(el('div', { cls: 'error-wrap' },
|
|
455
|
+
el('p', { cls: 'error-msg' }, `⚠ ${msg}`),
|
|
456
|
+
el('button', { cls: 'btn', onClick: () => loadView(view) }, 'Retry')
|
|
457
|
+
));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// Overview view
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
async function loadOverview() {
|
|
466
|
+
showLoading('overview');
|
|
467
|
+
try {
|
|
468
|
+
state.snapshot = await apiFetch('/api/snapshot');
|
|
469
|
+
renderOverview();
|
|
470
|
+
} catch (err) {
|
|
471
|
+
showError('overview', err.message);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function renderOverview() {
|
|
476
|
+
if (state.activeView !== 'overview') return;
|
|
477
|
+
const s = state.snapshot;
|
|
478
|
+
const main = getMain();
|
|
479
|
+
if (!main) return;
|
|
480
|
+
main.innerHTML = '';
|
|
481
|
+
|
|
482
|
+
if (!s) {
|
|
483
|
+
main.appendChild(el('div', { cls: 'loading-wrap' }, el('p', {}, 'No data yet.')));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const section = el('section', { cls: 'view-section' });
|
|
488
|
+
|
|
489
|
+
// Header
|
|
490
|
+
section.appendChild(el('div', { cls: 'view-header' },
|
|
491
|
+
el('h1', { cls: 'view-title' }, 'Overview'),
|
|
492
|
+
el('span', { cls: 'view-subtitle' }, `Updated ${fmtRelative(s.generatedAt)}`)
|
|
493
|
+
));
|
|
494
|
+
|
|
495
|
+
// Metric cards row
|
|
496
|
+
const cards = el('div', { cls: 'cards-row' });
|
|
497
|
+
cards.appendChild(metricCard('Repos', s.repos.total, `${s.repos.dirty} dirty · ${s.repos.stale} stale`, '#60a5fa'));
|
|
498
|
+
cards.appendChild(metricCard('Sessions (7d)', s.activity.sessions, `${fmtK(s.activity.tokens)} tokens`, '#4ade80'));
|
|
499
|
+
cards.appendChild(metricCard('Cost (7d)', `$${fmt(s.activity.estCostUsd)}`, `${s.activity.commits} commits`, '#fbbf24'));
|
|
500
|
+
cards.appendChild(metricCard('Tools', s.tools.installed, `of ${s.tools.total} detected`, '#a78bfa'));
|
|
501
|
+
cards.appendChild(metricCard('Genome', s.genome.entries, `${s.genome.projects} projects`, '#34d399'));
|
|
502
|
+
section.appendChild(cards);
|
|
503
|
+
|
|
504
|
+
// Recent Runs
|
|
505
|
+
if (s.runs && s.runs.length > 0) {
|
|
506
|
+
section.appendChild(el('h2', { cls: 'section-heading' }, 'Recent Runs'));
|
|
507
|
+
const table = buildRunsTable(s.runs);
|
|
508
|
+
section.appendChild(table);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Active Swarms
|
|
512
|
+
if (s.swarms && s.swarms.length > 0) {
|
|
513
|
+
section.appendChild(el('h2', { cls: 'section-heading' }, 'Active Swarms'));
|
|
514
|
+
const grid = el('div', { cls: 'swarms-grid' });
|
|
515
|
+
for (const sw of s.swarms) {
|
|
516
|
+
grid.appendChild(swarmCard(sw));
|
|
517
|
+
}
|
|
518
|
+
section.appendChild(grid);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// MCP servers
|
|
522
|
+
if (s.mcp && s.mcp.length > 0) {
|
|
523
|
+
section.appendChild(el('h2', { cls: 'section-heading' }, 'MCP Servers'));
|
|
524
|
+
const pills = el('div', { cls: 'mcp-pills' });
|
|
525
|
+
for (const m of s.mcp) {
|
|
526
|
+
pills.appendChild(el('span', {
|
|
527
|
+
cls: `mcp-pill ${m.ok ? 'ok' : 'fail'}`,
|
|
528
|
+
title: `${m.tools} tools`
|
|
529
|
+
}, m.name));
|
|
530
|
+
}
|
|
531
|
+
section.appendChild(pills);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
main.appendChild(section);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function metricCard(label, value, sub, accent = '#60a5fa') {
|
|
538
|
+
return el('div', { cls: 'metric-card', style: `--accent:${accent}` },
|
|
539
|
+
el('div', { cls: 'metric-value' }, String(value)),
|
|
540
|
+
el('div', { cls: 'metric-label' }, label),
|
|
541
|
+
el('div', { cls: 'metric-sub' }, sub)
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function swarmCard(sw) {
|
|
546
|
+
const card = el('div', { cls: 'swarm-card' });
|
|
547
|
+
card.appendChild(el('div', { cls: 'swarm-card-header' },
|
|
548
|
+
el('span', { cls: 'swarm-goal', title: sw.goal }, sw.goal),
|
|
549
|
+
statusBadge(sw.status)
|
|
550
|
+
));
|
|
551
|
+
if (sw.phase) card.appendChild(el('div', { cls: 'swarm-phase' }, `Phase: ${sw.phase}`));
|
|
552
|
+
card.appendChild(progressBar(sw.tasksDone, sw.tasksTotal));
|
|
553
|
+
// Click to navigate to swarm detail
|
|
554
|
+
if (sw.id) {
|
|
555
|
+
card.style.cursor = 'pointer';
|
|
556
|
+
card.addEventListener('click', () => {
|
|
557
|
+
navigate('swarms');
|
|
558
|
+
// store pending swarm to open
|
|
559
|
+
state._pendingSwarmId = sw.id;
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
return card;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
// Runs view
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
async function loadRuns() {
|
|
570
|
+
showLoading('runs');
|
|
571
|
+
try {
|
|
572
|
+
state.runs = await apiFetch('/api/runs');
|
|
573
|
+
renderRuns();
|
|
574
|
+
} catch (err) {
|
|
575
|
+
showError('runs', err.message);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function renderRuns() {
|
|
580
|
+
if (state.activeView !== 'runs') return;
|
|
581
|
+
const main = getMain();
|
|
582
|
+
if (!main) return;
|
|
583
|
+
main.innerHTML = '';
|
|
584
|
+
|
|
585
|
+
const section = el('section', { cls: 'view-section' });
|
|
586
|
+
section.appendChild(el('div', { cls: 'view-header' },
|
|
587
|
+
el('h1', { cls: 'view-title' }, 'Runs'),
|
|
588
|
+
el('span', { cls: 'view-subtitle' }, `${state.runs.length} run${state.runs.length !== 1 ? 's' : ''}`)
|
|
589
|
+
));
|
|
590
|
+
|
|
591
|
+
// M32: dispatch panel
|
|
592
|
+
section.appendChild(buildDispatchPanel());
|
|
593
|
+
|
|
594
|
+
if (state.runs.length === 0) {
|
|
595
|
+
section.appendChild(el('div', { cls: 'empty-state' },
|
|
596
|
+
el('p', {}, 'No runs recorded yet.'),
|
|
597
|
+
el('p', { cls: 'hint' }, 'Use the dispatch panel above or run `ashlr run "your goal"` in your terminal.')
|
|
598
|
+
));
|
|
599
|
+
} else {
|
|
600
|
+
section.appendChild(buildRunsTable(state.runs, true));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
main.appendChild(section);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
// M32: Dispatch panel (inside Runs view)
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
function buildDispatchPanel() {
|
|
611
|
+
const snap = state.snapshot;
|
|
612
|
+
const dispatchEnabled = snap ? snap.dispatchEnabled === true : false;
|
|
613
|
+
const token = getToken();
|
|
614
|
+
const canDispatch = dispatchEnabled && Boolean(token);
|
|
615
|
+
|
|
616
|
+
// Disable reason shown as a note under the button
|
|
617
|
+
let disabledReason = '';
|
|
618
|
+
if (!dispatchEnabled) disabledReason = 'Start the server with --allow-dispatch to enable dispatch.';
|
|
619
|
+
else if (!token) disabledReason = 'Enter your session token (gear icon) to enable dispatch.';
|
|
620
|
+
|
|
621
|
+
const panel = el('div', { cls: 'dispatch-panel' });
|
|
622
|
+
panel.appendChild(el('h2', { cls: 'dispatch-panel__title' }, 'Dispatch a run'));
|
|
623
|
+
|
|
624
|
+
// Goal textarea
|
|
625
|
+
const goalInput = el('textarea', {
|
|
626
|
+
cls: 'input dispatch-panel__goal',
|
|
627
|
+
placeholder: 'Goal — what should the agent do?',
|
|
628
|
+
rows: '2',
|
|
629
|
+
'aria-label': 'Goal',
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// Budget input
|
|
633
|
+
const budgetInput = el('input', {
|
|
634
|
+
type: 'number',
|
|
635
|
+
cls: 'input dispatch-panel__budget',
|
|
636
|
+
placeholder: 'Max tokens (optional)',
|
|
637
|
+
min: '1000',
|
|
638
|
+
max: '200000',
|
|
639
|
+
step: '1000',
|
|
640
|
+
'aria-label': 'Max tokens budget',
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Estimate line
|
|
644
|
+
const estimateLine = el('div', { cls: 'dispatch-panel__estimate', id: 'dispatch-estimate', 'aria-live': 'polite' });
|
|
645
|
+
|
|
646
|
+
// Dispatch button
|
|
647
|
+
const dispatchBtn = el('button', {
|
|
648
|
+
cls: 'btn btn-primary dispatch-panel__btn',
|
|
649
|
+
type: 'button',
|
|
650
|
+
...(canDispatch ? {} : { disabled: 'disabled' }),
|
|
651
|
+
}, 'Dispatch');
|
|
652
|
+
if (!canDispatch && disabledReason) {
|
|
653
|
+
dispatchBtn.title = disabledReason;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Result line
|
|
657
|
+
const resultLine = el('div', { cls: 'dispatch-panel__result', 'aria-live': 'polite' });
|
|
658
|
+
|
|
659
|
+
// Debounced estimate fetch on input
|
|
660
|
+
function scheduleEstimate() {
|
|
661
|
+
if (state._estimateTimer) clearTimeout(state._estimateTimer);
|
|
662
|
+
state._estimateTimer = setTimeout(async () => {
|
|
663
|
+
const goal = goalInput.value.trim();
|
|
664
|
+
if (!goal) { estimateLine.textContent = ''; return; }
|
|
665
|
+
estimateLine.textContent = 'Estimating…';
|
|
666
|
+
try {
|
|
667
|
+
const params = new URLSearchParams({ kind: 'run', goal });
|
|
668
|
+
const maxTok = parseInt(budgetInput.value, 10);
|
|
669
|
+
if (!isNaN(maxTok) && maxTok > 0) params.set('maxTokens', String(maxTok));
|
|
670
|
+
const est = await apiFetch(`/api/estimate?${params}`);
|
|
671
|
+
const med = est.tokens?.median ?? '—';
|
|
672
|
+
const costMed = est.estCostUsd?.median != null ? `$${est.estCostUsd.median.toFixed(4)}` : '—';
|
|
673
|
+
const conf = est.confidence ?? '—';
|
|
674
|
+
estimateLine.textContent = `Est: ~${fmtK(med)} tokens · ${costMed} (${conf} confidence, n=${est.sampleSize ?? 0})`;
|
|
675
|
+
} catch (err) {
|
|
676
|
+
estimateLine.textContent = `Estimate unavailable: ${err.message}`;
|
|
677
|
+
}
|
|
678
|
+
}, 400);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
goalInput.addEventListener('input', scheduleEstimate);
|
|
682
|
+
budgetInput.addEventListener('input', scheduleEstimate);
|
|
683
|
+
|
|
684
|
+
dispatchBtn.addEventListener('click', async () => {
|
|
685
|
+
const goal = goalInput.value.trim();
|
|
686
|
+
if (!goal) { resultLine.textContent = 'Goal is required.'; return; }
|
|
687
|
+
if (!canDispatch) { resultLine.textContent = disabledReason || 'Dispatch not available.'; return; }
|
|
688
|
+
dispatchBtn.disabled = true;
|
|
689
|
+
resultLine.textContent = 'Dispatching…';
|
|
690
|
+
try {
|
|
691
|
+
const body = { goal };
|
|
692
|
+
const maxTok = parseInt(budgetInput.value, 10);
|
|
693
|
+
if (!isNaN(maxTok) && maxTok > 0) body.maxTokens = maxTok;
|
|
694
|
+
const res = await fetch('/api/run', {
|
|
695
|
+
method: 'POST',
|
|
696
|
+
headers: { 'Content-Type': 'application/json', 'x-ashlr-token': token },
|
|
697
|
+
body: JSON.stringify(body),
|
|
698
|
+
});
|
|
699
|
+
const json = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
|
700
|
+
if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);
|
|
701
|
+
resultLine.textContent = `Dispatched: run ${json.id ?? ''} (${json.status ?? 'queued'})`;
|
|
702
|
+
resultLine.className = 'dispatch-panel__result dispatch-panel__result--ok';
|
|
703
|
+
goalInput.value = '';
|
|
704
|
+
estimateLine.textContent = '';
|
|
705
|
+
// Refresh runs list
|
|
706
|
+
await loadRuns();
|
|
707
|
+
} catch (err) {
|
|
708
|
+
resultLine.textContent = `Error: ${err.message}`;
|
|
709
|
+
resultLine.className = 'dispatch-panel__result dispatch-panel__result--err';
|
|
710
|
+
} finally {
|
|
711
|
+
dispatchBtn.disabled = !canDispatch;
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
const row = el('div', { cls: 'dispatch-panel__row' });
|
|
716
|
+
row.appendChild(goalInput);
|
|
717
|
+
row.appendChild(budgetInput);
|
|
718
|
+
|
|
719
|
+
const actions = el('div', { cls: 'dispatch-panel__actions' });
|
|
720
|
+
actions.appendChild(estimateLine);
|
|
721
|
+
actions.appendChild(dispatchBtn);
|
|
722
|
+
|
|
723
|
+
if (!canDispatch && disabledReason) {
|
|
724
|
+
actions.appendChild(el('span', { cls: 'dispatch-panel__note' }, disabledReason));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
panel.appendChild(row);
|
|
728
|
+
panel.appendChild(actions);
|
|
729
|
+
panel.appendChild(resultLine);
|
|
730
|
+
return panel;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function buildRunsTable(runs, full = false) {
|
|
734
|
+
const wrap = el('div', { cls: 'table-wrap' });
|
|
735
|
+
const table = el('table', { cls: 'data-table' });
|
|
736
|
+
|
|
737
|
+
const thead = el('thead', {});
|
|
738
|
+
const headerRow = el('tr', {});
|
|
739
|
+
for (const h of ['Goal', 'Status', 'Engine', 'Tokens', 'Cost', 'Updated']) {
|
|
740
|
+
headerRow.appendChild(el('th', {}, h));
|
|
741
|
+
}
|
|
742
|
+
thead.appendChild(headerRow);
|
|
743
|
+
table.appendChild(thead);
|
|
744
|
+
|
|
745
|
+
const tbody = el('tbody', {});
|
|
746
|
+
for (const run of runs) {
|
|
747
|
+
const tokens = (run.usage?.tokensIn ?? 0) + (run.usage?.tokensOut ?? 0);
|
|
748
|
+
const cost = run.usage?.estCostUsd ?? 0;
|
|
749
|
+
const tr = el('tr', { cls: 'table-row', style: 'cursor:pointer' });
|
|
750
|
+
tr.appendChild(el('td', { cls: 'goal-cell', title: run.goal }, truncate(run.goal, 60)));
|
|
751
|
+
tr.appendChild(el('td', {}, statusBadge(run.status)));
|
|
752
|
+
tr.appendChild(el('td', {}, run.engine ?? '—'));
|
|
753
|
+
tr.appendChild(el('td', { cls: 'num' }, fmtK(tokens)));
|
|
754
|
+
tr.appendChild(el('td', { cls: 'num' }, `$${fmt(cost)}`));
|
|
755
|
+
tr.appendChild(el('td', { cls: 'ts' }, fmtRelative(run.updatedAt)));
|
|
756
|
+
if (full) {
|
|
757
|
+
tr.addEventListener('click', () => showRunDetail(run));
|
|
758
|
+
}
|
|
759
|
+
tbody.appendChild(tr);
|
|
760
|
+
}
|
|
761
|
+
table.appendChild(tbody);
|
|
762
|
+
wrap.appendChild(table);
|
|
763
|
+
return wrap;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function showRunDetail(run) {
|
|
767
|
+
const main = getMain();
|
|
768
|
+
if (!main) return;
|
|
769
|
+
main.innerHTML = '';
|
|
770
|
+
|
|
771
|
+
const section = el('section', { cls: 'view-section' });
|
|
772
|
+
section.appendChild(el('div', { cls: 'view-header' },
|
|
773
|
+
el('button', { cls: 'btn back-btn', onClick: () => { state.activeView = 'runs'; renderRuns(); } }, '← Back'),
|
|
774
|
+
el('h1', { cls: 'view-title' }, 'Run Detail'),
|
|
775
|
+
statusBadge(run.status)
|
|
776
|
+
));
|
|
777
|
+
|
|
778
|
+
// Meta
|
|
779
|
+
section.appendChild(infoGrid([
|
|
780
|
+
['ID', run.id],
|
|
781
|
+
['Goal', run.goal],
|
|
782
|
+
['Engine', run.engine ?? '—'],
|
|
783
|
+
['Provider', run.provider ?? '—'],
|
|
784
|
+
['Created', fmtDate(run.createdAt)],
|
|
785
|
+
['Updated', fmtDate(run.updatedAt)],
|
|
786
|
+
['Tokens In', fmtK(run.usage?.tokensIn ?? 0)],
|
|
787
|
+
['Tokens Out', fmtK(run.usage?.tokensOut ?? 0)],
|
|
788
|
+
['Cost', `$${fmt(run.usage?.estCostUsd ?? 0)}`],
|
|
789
|
+
['Steps', run.usage?.steps ?? 0],
|
|
790
|
+
]));
|
|
791
|
+
|
|
792
|
+
// Task list
|
|
793
|
+
if (run.tasks && run.tasks.length > 0) {
|
|
794
|
+
section.appendChild(el('h2', { cls: 'section-heading' }, 'Tasks'));
|
|
795
|
+
const taskList = el('div', { cls: 'task-list' });
|
|
796
|
+
for (const t of run.tasks) {
|
|
797
|
+
taskList.appendChild(taskRow(t));
|
|
798
|
+
}
|
|
799
|
+
section.appendChild(taskList);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Result
|
|
803
|
+
if (run.result) {
|
|
804
|
+
section.appendChild(el('h2', { cls: 'section-heading' }, 'Result'));
|
|
805
|
+
section.appendChild(el('pre', { cls: 'result-pre' }, run.result));
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
main.appendChild(section);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function taskRow(t) {
|
|
812
|
+
const color = STATUS_COLOR[t.status] ?? '#9ca3af';
|
|
813
|
+
const row = el('div', { cls: 'task-row', style: `border-left:3px solid ${color}` });
|
|
814
|
+
row.appendChild(el('div', { cls: 'task-header' },
|
|
815
|
+
el('span', { cls: 'task-id' }, t.id),
|
|
816
|
+
statusBadge(t.status)
|
|
817
|
+
));
|
|
818
|
+
row.appendChild(el('div', { cls: 'task-goal' }, t.goal));
|
|
819
|
+
if (t.deps && t.deps.length > 0) {
|
|
820
|
+
row.appendChild(el('div', { cls: 'task-deps' }, `Deps: ${t.deps.join(', ')}`));
|
|
821
|
+
}
|
|
822
|
+
if (t.error) {
|
|
823
|
+
row.appendChild(el('div', { cls: 'task-error' }, `Error: ${t.error}`));
|
|
824
|
+
}
|
|
825
|
+
return row;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
// Swarms view
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
|
|
832
|
+
async function loadSwarms() {
|
|
833
|
+
showLoading('swarms');
|
|
834
|
+
try {
|
|
835
|
+
state.swarms = await apiFetch('/api/swarms');
|
|
836
|
+
renderSwarms();
|
|
837
|
+
} catch (err) {
|
|
838
|
+
showError('swarms', err.message);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function renderSwarms() {
|
|
843
|
+
if (state.activeView !== 'swarms') return;
|
|
844
|
+
const main = getMain();
|
|
845
|
+
if (!main) return;
|
|
846
|
+
main.innerHTML = '';
|
|
847
|
+
|
|
848
|
+
const section = el('section', { cls: 'view-section' });
|
|
849
|
+
section.appendChild(el('div', { cls: 'view-header' },
|
|
850
|
+
el('h1', { cls: 'view-title' }, 'Swarms'),
|
|
851
|
+
el('span', { cls: 'view-subtitle' }, `${state.swarms.length} swarm${state.swarms.length !== 1 ? 's' : ''}`)
|
|
852
|
+
));
|
|
853
|
+
|
|
854
|
+
if (state.swarms.length === 0) {
|
|
855
|
+
section.appendChild(el('div', { cls: 'empty-state' },
|
|
856
|
+
el('p', {}, 'No swarms recorded yet.'),
|
|
857
|
+
el('p', { cls: 'hint' }, 'Run `ashlr swarm "your goal"` to start one.')
|
|
858
|
+
));
|
|
859
|
+
main.appendChild(section);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Check if we should auto-open a pending swarm
|
|
864
|
+
const pendingId = state._pendingSwarmId;
|
|
865
|
+
delete state._pendingSwarmId;
|
|
866
|
+
|
|
867
|
+
if (pendingId) {
|
|
868
|
+
const sw = state.swarms.find((s) => s.id === pendingId);
|
|
869
|
+
if (sw) { renderSwarmDetail(sw, section, main); return; }
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// List
|
|
873
|
+
const list = el('div', { cls: 'swarm-list' });
|
|
874
|
+
for (const sw of state.swarms) {
|
|
875
|
+
const row = el('div', { cls: 'swarm-list-row', style: 'cursor:pointer' });
|
|
876
|
+
|
|
877
|
+
const tasksDone = sw.tasks.filter((t) => t.status === 'done' || t.status === 'skipped').length;
|
|
878
|
+
const tasksTotal = sw.tasks.length;
|
|
879
|
+
|
|
880
|
+
row.appendChild(el('div', { cls: 'swarm-list-main' },
|
|
881
|
+
el('div', { cls: 'swarm-goal', title: sw.goal }, truncate(sw.goal, 70)),
|
|
882
|
+
el('div', { cls: 'swarm-meta-row' },
|
|
883
|
+
statusBadge(sw.status),
|
|
884
|
+
el('span', { cls: 'ts' }, fmtRelative(sw.updatedAt)),
|
|
885
|
+
el('span', {}, `${tasksTotal} tasks`)
|
|
886
|
+
)
|
|
887
|
+
));
|
|
888
|
+
row.appendChild(progressBar(tasksDone, tasksTotal));
|
|
889
|
+
row.addEventListener('click', () => {
|
|
890
|
+
main.innerHTML = '';
|
|
891
|
+
const sec = el('section', { cls: 'view-section' });
|
|
892
|
+
renderSwarmDetail(sw, sec, main);
|
|
893
|
+
});
|
|
894
|
+
list.appendChild(row);
|
|
895
|
+
}
|
|
896
|
+
section.appendChild(list);
|
|
897
|
+
main.appendChild(section);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function renderSwarmDetail(sw, section, main) {
|
|
901
|
+
section.appendChild(el('div', { cls: 'view-header' },
|
|
902
|
+
el('button', { cls: 'btn back-btn', onClick: () => loadSwarms() }, '← Back'),
|
|
903
|
+
el('h1', { cls: 'view-title', title: sw.goal }, truncate(sw.goal, 50)),
|
|
904
|
+
statusBadge(sw.status)
|
|
905
|
+
));
|
|
906
|
+
|
|
907
|
+
// Meta
|
|
908
|
+
const tasksDone = sw.tasks.filter((t) => t.status === 'done' || t.status === 'skipped').length;
|
|
909
|
+
section.appendChild(infoGrid([
|
|
910
|
+
['ID', sw.id],
|
|
911
|
+
['Status', sw.status],
|
|
912
|
+
['Tasks', `${tasksDone} / ${sw.tasks.length} done`],
|
|
913
|
+
['Cost', `$${fmt(sw.usage?.estCostUsd ?? 0)}`],
|
|
914
|
+
['Tokens', fmtK((sw.usage?.tokensIn ?? 0) + (sw.usage?.tokensOut ?? 0))],
|
|
915
|
+
['Created', fmtDate(sw.createdAt)],
|
|
916
|
+
['Updated', fmtDate(sw.updatedAt)],
|
|
917
|
+
]));
|
|
918
|
+
|
|
919
|
+
// Burndown bar
|
|
920
|
+
section.appendChild(el('h2', { cls: 'section-heading' }, 'Progress'));
|
|
921
|
+
section.appendChild(progressBar(tasksDone, sw.tasks.length));
|
|
922
|
+
|
|
923
|
+
// DAG Graph
|
|
924
|
+
section.appendChild(el('h2', { cls: 'section-heading' }, 'Task DAG'));
|
|
925
|
+
const dagSvg = buildSwarmDag(sw);
|
|
926
|
+
section.appendChild(el('div', { cls: 'dag-wrap' }, dagSvg));
|
|
927
|
+
|
|
928
|
+
// Task detail list
|
|
929
|
+
section.appendChild(el('h2', { cls: 'section-heading' }, 'Tasks'));
|
|
930
|
+
const taskList = el('div', { cls: 'task-list' });
|
|
931
|
+
for (const t of sw.tasks) {
|
|
932
|
+
const spec = sw.plan?.tasks?.find((p) => p.id === t.id);
|
|
933
|
+
const taskRun = Object.assign({}, spec ?? {}, t);
|
|
934
|
+
taskList.appendChild(taskRow(taskRun));
|
|
935
|
+
}
|
|
936
|
+
section.appendChild(taskList);
|
|
937
|
+
|
|
938
|
+
// Result
|
|
939
|
+
if (sw.result) {
|
|
940
|
+
section.appendChild(el('h2', { cls: 'section-heading' }, 'Result'));
|
|
941
|
+
section.appendChild(el('pre', { cls: 'result-pre' }, sw.result));
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
main.appendChild(section);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ---------------------------------------------------------------------------
|
|
948
|
+
// Swarm DAG — SVG node-link diagram
|
|
949
|
+
// ---------------------------------------------------------------------------
|
|
950
|
+
|
|
951
|
+
function buildSwarmDag(sw) {
|
|
952
|
+
// Merge plan tasks (spec) with runtime tasks for status + goal
|
|
953
|
+
const specTasks = sw.plan?.tasks ?? [];
|
|
954
|
+
const runTasks = sw.tasks ?? [];
|
|
955
|
+
|
|
956
|
+
// Build combined task list: spec carries goal+deps+phase, run carries status
|
|
957
|
+
const taskMap = new Map();
|
|
958
|
+
for (const s of specTasks) taskMap.set(s.id, { ...s });
|
|
959
|
+
for (const r of runTasks) {
|
|
960
|
+
const existing = taskMap.get(r.id) ?? {};
|
|
961
|
+
taskMap.set(r.id, { ...existing, ...r });
|
|
962
|
+
}
|
|
963
|
+
const tasks = [...taskMap.values()];
|
|
964
|
+
|
|
965
|
+
if (tasks.length === 0) {
|
|
966
|
+
return el('div', { cls: 'dag-empty' }, 'No tasks in this swarm.');
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Layout: columns by phase, rows within phase
|
|
970
|
+
const phaseGroups = {};
|
|
971
|
+
for (const t of tasks) {
|
|
972
|
+
const ph = t.phase ?? 'build';
|
|
973
|
+
if (!phaseGroups[ph]) phaseGroups[ph] = [];
|
|
974
|
+
phaseGroups[ph].push(t);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const phases = Object.keys(phaseGroups).sort(
|
|
978
|
+
(a, b) => (PHASE_ORDER[a] ?? 99) - (PHASE_ORDER[b] ?? 99)
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
// Dimensions
|
|
982
|
+
const NODE_W = 140;
|
|
983
|
+
const NODE_H = 48;
|
|
984
|
+
const H_GAP = 60; // gap between columns
|
|
985
|
+
const V_GAP = 20; // gap between rows within a column
|
|
986
|
+
const PAD = 20;
|
|
987
|
+
|
|
988
|
+
const colX = {};
|
|
989
|
+
let x = PAD;
|
|
990
|
+
for (const ph of phases) {
|
|
991
|
+
colX[ph] = x;
|
|
992
|
+
x += NODE_W + H_GAP;
|
|
993
|
+
}
|
|
994
|
+
const totalW = x + PAD;
|
|
995
|
+
|
|
996
|
+
// Compute Y per task
|
|
997
|
+
const nodePos = {};
|
|
998
|
+
for (const ph of phases) {
|
|
999
|
+
const group = phaseGroups[ph];
|
|
1000
|
+
let y = PAD;
|
|
1001
|
+
for (const t of group) {
|
|
1002
|
+
nodePos[t.id] = { x: colX[ph], y, cx: colX[ph] + NODE_W / 2, cy: y + NODE_H / 2 };
|
|
1003
|
+
y += NODE_H + V_GAP;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Total SVG height
|
|
1008
|
+
const totalH = Math.max(...Object.values(nodePos).map((p) => p.y + NODE_H)) + PAD * 2;
|
|
1009
|
+
|
|
1010
|
+
const svg = svgEl('svg', {
|
|
1011
|
+
width: totalW,
|
|
1012
|
+
height: totalH,
|
|
1013
|
+
viewBox: `0 0 ${totalW} ${totalH}`,
|
|
1014
|
+
class: 'dag-svg',
|
|
1015
|
+
'aria-label': 'Swarm task dependency graph'
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
// Phase column labels
|
|
1019
|
+
for (const ph of phases) {
|
|
1020
|
+
svg.appendChild(svgEl('text', {
|
|
1021
|
+
x: colX[ph] + NODE_W / 2,
|
|
1022
|
+
y: PAD - 6,
|
|
1023
|
+
'text-anchor': 'middle',
|
|
1024
|
+
class: 'dag-phase-label'
|
|
1025
|
+
}, ph));
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Draw edges (dep arrows) BELOW nodes
|
|
1029
|
+
for (const t of tasks) {
|
|
1030
|
+
if (!t.deps || !t.id || !nodePos[t.id]) continue;
|
|
1031
|
+
const to = nodePos[t.id];
|
|
1032
|
+
for (const depId of t.deps) {
|
|
1033
|
+
const from = nodePos[depId];
|
|
1034
|
+
if (!from) continue;
|
|
1035
|
+
// Draw a bezier from right-center of dep node to left-center of this node
|
|
1036
|
+
const x1 = from.x + NODE_W;
|
|
1037
|
+
const y1 = from.cy;
|
|
1038
|
+
const x2 = to.x;
|
|
1039
|
+
const y2 = to.cy;
|
|
1040
|
+
const mx = (x1 + x2) / 2;
|
|
1041
|
+
svg.appendChild(svgEl('path', {
|
|
1042
|
+
d: `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`,
|
|
1043
|
+
class: 'dag-edge',
|
|
1044
|
+
fill: 'none',
|
|
1045
|
+
'marker-end': 'url(#arrow)'
|
|
1046
|
+
}));
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Arrow marker
|
|
1051
|
+
const defs = svgEl('defs');
|
|
1052
|
+
const marker = svgEl('marker', {
|
|
1053
|
+
id: 'arrow',
|
|
1054
|
+
markerWidth: '8',
|
|
1055
|
+
markerHeight: '8',
|
|
1056
|
+
refX: '6',
|
|
1057
|
+
refY: '3',
|
|
1058
|
+
orient: 'auto'
|
|
1059
|
+
});
|
|
1060
|
+
marker.appendChild(svgEl('path', {
|
|
1061
|
+
d: 'M0,0 L0,6 L8,3 z',
|
|
1062
|
+
class: 'dag-arrow-head'
|
|
1063
|
+
}));
|
|
1064
|
+
defs.appendChild(marker);
|
|
1065
|
+
svg.insertBefore(defs, svg.firstChild);
|
|
1066
|
+
|
|
1067
|
+
// Draw nodes
|
|
1068
|
+
for (const t of tasks) {
|
|
1069
|
+
if (!nodePos[t.id]) continue;
|
|
1070
|
+
const pos = nodePos[t.id];
|
|
1071
|
+
const color = STATUS_COLOR[t.status] ?? '#6b7280';
|
|
1072
|
+
const g = svgEl('g', { class: 'dag-node', transform: `translate(${pos.x},${pos.y})` });
|
|
1073
|
+
|
|
1074
|
+
// Node rect
|
|
1075
|
+
g.appendChild(svgEl('rect', {
|
|
1076
|
+
width: NODE_W,
|
|
1077
|
+
height: NODE_H,
|
|
1078
|
+
rx: 6,
|
|
1079
|
+
ry: 6,
|
|
1080
|
+
class: 'dag-node-rect',
|
|
1081
|
+
style: `stroke:${color};fill:${color}18`
|
|
1082
|
+
}));
|
|
1083
|
+
|
|
1084
|
+
// Status dot
|
|
1085
|
+
g.appendChild(svgEl('circle', {
|
|
1086
|
+
cx: 10,
|
|
1087
|
+
cy: NODE_H / 2,
|
|
1088
|
+
r: 4,
|
|
1089
|
+
style: `fill:${color}`
|
|
1090
|
+
}));
|
|
1091
|
+
|
|
1092
|
+
// Task id
|
|
1093
|
+
g.appendChild(svgEl('text', {
|
|
1094
|
+
x: 20,
|
|
1095
|
+
y: 17,
|
|
1096
|
+
class: 'dag-node-id'
|
|
1097
|
+
}, truncate(t.id, 18)));
|
|
1098
|
+
|
|
1099
|
+
// Goal text
|
|
1100
|
+
g.appendChild(svgEl('text', {
|
|
1101
|
+
x: 20,
|
|
1102
|
+
y: 33,
|
|
1103
|
+
class: 'dag-node-goal'
|
|
1104
|
+
}, truncate(t.goal ?? '', 20)));
|
|
1105
|
+
|
|
1106
|
+
svg.appendChild(g);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
return svg;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// ---------------------------------------------------------------------------
|
|
1113
|
+
// Pulse view
|
|
1114
|
+
// ---------------------------------------------------------------------------
|
|
1115
|
+
|
|
1116
|
+
async function loadPulse(window = '7d') {
|
|
1117
|
+
showLoading('pulse');
|
|
1118
|
+
try {
|
|
1119
|
+
state.pulse = await apiFetch(`/api/pulse?window=${window}`);
|
|
1120
|
+
renderPulse();
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
showError('pulse', err.message);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function renderPulse() {
|
|
1127
|
+
if (state.activeView !== 'pulse') return;
|
|
1128
|
+
const p = state.pulse;
|
|
1129
|
+
const main = getMain();
|
|
1130
|
+
if (!main) return;
|
|
1131
|
+
main.innerHTML = '';
|
|
1132
|
+
|
|
1133
|
+
if (!p) {
|
|
1134
|
+
main.appendChild(el('div', { cls: 'loading-wrap' }, el('p', {}, 'No data yet.')));
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const section = el('section', { cls: 'view-section' });
|
|
1139
|
+
|
|
1140
|
+
// Header + window picker
|
|
1141
|
+
const windowPicker = el('div', { cls: 'window-picker' });
|
|
1142
|
+
for (const w of ['1d', '7d', '30d']) {
|
|
1143
|
+
const btn = el('button', {
|
|
1144
|
+
cls: `btn window-btn ${p.window === w ? 'active' : ''}`,
|
|
1145
|
+
onClick: () => loadPulse(w)
|
|
1146
|
+
}, w);
|
|
1147
|
+
windowPicker.appendChild(btn);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
section.appendChild(el('div', { cls: 'view-header' },
|
|
1151
|
+
el('h1', { cls: 'view-title' }, 'Pulse'),
|
|
1152
|
+
windowPicker
|
|
1153
|
+
));
|
|
1154
|
+
|
|
1155
|
+
// Totals row
|
|
1156
|
+
const budget = p.budget ?? {};
|
|
1157
|
+
const cards = el('div', { cls: 'cards-row' });
|
|
1158
|
+
cards.appendChild(metricCard('Cost', `$${fmt(p.totals?.estCostUsd ?? 0)}`, p.window, '#fbbf24'));
|
|
1159
|
+
cards.appendChild(metricCard('Tokens In', fmtK(p.totals?.tokensIn ?? 0), '', '#60a5fa'));
|
|
1160
|
+
cards.appendChild(metricCard('Tokens Out', fmtK(p.totals?.tokensOut ?? 0), '', '#a78bfa'));
|
|
1161
|
+
cards.appendChild(metricCard('Sessions', p.totals?.sessions ?? 0, `${p.totals?.commits ?? 0} commits`, '#4ade80'));
|
|
1162
|
+
section.appendChild(cards);
|
|
1163
|
+
|
|
1164
|
+
// Budget alert
|
|
1165
|
+
if (budget.level && budget.level !== 'ok') {
|
|
1166
|
+
const levelColor = budget.level === 'over' ? '#f87171' : '#fbbf24';
|
|
1167
|
+
section.appendChild(el('div', {
|
|
1168
|
+
cls: 'budget-alert',
|
|
1169
|
+
style: `border-left:4px solid ${levelColor};background:${levelColor}18`
|
|
1170
|
+
}, el('strong', {}, budget.level.toUpperCase() + ': '), budget.message ?? ''));
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Daily cost chart
|
|
1174
|
+
if (p.byDay && p.byDay.length > 0) {
|
|
1175
|
+
section.appendChild(el('h2', { cls: 'section-heading' }, 'Daily Cost'));
|
|
1176
|
+
section.appendChild(buildBarChart(
|
|
1177
|
+
p.byDay.map((d) => ({ label: d.day.slice(5), value: d.estCostUsd, sub: `$${fmt(d.estCostUsd)}` })),
|
|
1178
|
+
'#fbbf24',
|
|
1179
|
+
{ height: 160, prefix: '$' }
|
|
1180
|
+
));
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Daily tokens chart
|
|
1184
|
+
if (p.byDay && p.byDay.length > 0) {
|
|
1185
|
+
section.appendChild(el('h2', { cls: 'section-heading' }, 'Daily Tokens'));
|
|
1186
|
+
section.appendChild(buildBarChart(
|
|
1187
|
+
p.byDay.map((d) => ({ label: d.day.slice(5), value: d.tokensIn + d.tokensOut, sub: fmtK(d.tokensIn + d.tokensOut) })),
|
|
1188
|
+
'#60a5fa',
|
|
1189
|
+
{ height: 160 }
|
|
1190
|
+
));
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// By project table
|
|
1194
|
+
if (p.byProject && p.byProject.length > 0) {
|
|
1195
|
+
section.appendChild(el('h2', { cls: 'section-heading' }, 'By Project'));
|
|
1196
|
+
section.appendChild(buildProjectTable(p.byProject));
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// By model chart
|
|
1200
|
+
if (p.byModel && p.byModel.length > 0) {
|
|
1201
|
+
section.appendChild(el('h2', { cls: 'section-heading' }, 'By Model'));
|
|
1202
|
+
section.appendChild(buildBarChart(
|
|
1203
|
+
p.byModel.map((m) => ({ label: shortModel(m.model), value: m.estCostUsd, sub: `$${fmt(m.estCostUsd)} · ${m.calls} calls` })),
|
|
1204
|
+
'#a78bfa',
|
|
1205
|
+
{ height: 160, prefix: '$' }
|
|
1206
|
+
));
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
main.appendChild(section);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function shortModel(model) {
|
|
1213
|
+
if (!model) return '—';
|
|
1214
|
+
const parts = model.split(/[/-]/);
|
|
1215
|
+
return parts[parts.length - 1] ?? model;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// SVG bar chart
|
|
1219
|
+
function buildBarChart(items, color, opts = {}) {
|
|
1220
|
+
const { height = 160, prefix = '' } = opts;
|
|
1221
|
+
if (!items.length) return el('div', { cls: 'empty-state' }, 'No data.');
|
|
1222
|
+
|
|
1223
|
+
const BAR_W = 36;
|
|
1224
|
+
const GAP = 8;
|
|
1225
|
+
const LABEL_H = 32;
|
|
1226
|
+
const PAD = 16;
|
|
1227
|
+
const chartH = height;
|
|
1228
|
+
const totalW = items.length * (BAR_W + GAP) + PAD * 2;
|
|
1229
|
+
const svgH = chartH + LABEL_H + PAD;
|
|
1230
|
+
|
|
1231
|
+
const maxVal = Math.max(...items.map((i) => i.value), 0.001);
|
|
1232
|
+
|
|
1233
|
+
const svg = svgEl('svg', {
|
|
1234
|
+
width: totalW,
|
|
1235
|
+
height: svgH,
|
|
1236
|
+
viewBox: `0 0 ${totalW} ${svgH}`,
|
|
1237
|
+
class: 'bar-chart-svg'
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
// Max-value axis label (top-right, clear of the bars) — gives cost charts a
|
|
1241
|
+
// currency affordance via `prefix` (e.g. "$12.50" / "1.2K").
|
|
1242
|
+
svg.appendChild(svgEl('text', {
|
|
1243
|
+
x: totalW - PAD,
|
|
1244
|
+
y: PAD - 4,
|
|
1245
|
+
'text-anchor': 'end',
|
|
1246
|
+
class: 'bar-axis-max',
|
|
1247
|
+
style: 'fill:#94a3b8;font-size:10px'
|
|
1248
|
+
}, `max ${prefix}${maxVal >= 1000 ? fmtK(maxVal) : fmt(maxVal)}`));
|
|
1249
|
+
|
|
1250
|
+
// Baseline
|
|
1251
|
+
svg.appendChild(svgEl('line', {
|
|
1252
|
+
x1: PAD,
|
|
1253
|
+
y1: chartH + PAD / 2,
|
|
1254
|
+
x2: totalW - PAD,
|
|
1255
|
+
y2: chartH + PAD / 2,
|
|
1256
|
+
class: 'bar-baseline'
|
|
1257
|
+
}));
|
|
1258
|
+
|
|
1259
|
+
items.forEach((item, i) => {
|
|
1260
|
+
const x = PAD + i * (BAR_W + GAP);
|
|
1261
|
+
const barH = Math.max(2, (item.value / maxVal) * chartH);
|
|
1262
|
+
const y = chartH + PAD / 2 - barH;
|
|
1263
|
+
|
|
1264
|
+
// Bar rect
|
|
1265
|
+
const rect = svgEl('rect', {
|
|
1266
|
+
x,
|
|
1267
|
+
y,
|
|
1268
|
+
width: BAR_W,
|
|
1269
|
+
height: barH,
|
|
1270
|
+
rx: 3,
|
|
1271
|
+
class: 'bar-rect',
|
|
1272
|
+
style: `fill:${color}cc`,
|
|
1273
|
+
'data-val': item.sub ?? item.value
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
// Tooltip via title
|
|
1277
|
+
const title = svgEl('title');
|
|
1278
|
+
title.textContent = `${item.label}: ${item.sub ?? item.value}`;
|
|
1279
|
+
rect.appendChild(title);
|
|
1280
|
+
svg.appendChild(rect);
|
|
1281
|
+
|
|
1282
|
+
// X-axis label
|
|
1283
|
+
svg.appendChild(svgEl('text', {
|
|
1284
|
+
x: x + BAR_W / 2,
|
|
1285
|
+
y: chartH + PAD / 2 + 16,
|
|
1286
|
+
'text-anchor': 'middle',
|
|
1287
|
+
class: 'bar-label'
|
|
1288
|
+
}, item.label));
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
// Wrap in scrollable div in case many bars
|
|
1292
|
+
const wrap = el('div', { cls: 'bar-chart-wrap' });
|
|
1293
|
+
wrap.appendChild(svg);
|
|
1294
|
+
return wrap;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function buildProjectTable(byProject) {
|
|
1298
|
+
const wrap = el('div', { cls: 'table-wrap' });
|
|
1299
|
+
const table = el('table', { cls: 'data-table' });
|
|
1300
|
+
const thead = el('thead', {});
|
|
1301
|
+
const hr = el('tr', {});
|
|
1302
|
+
for (const h of ['Project', 'Sessions', 'Commits', 'Tokens', 'Cost']) {
|
|
1303
|
+
hr.appendChild(el('th', {}, h));
|
|
1304
|
+
}
|
|
1305
|
+
thead.appendChild(hr);
|
|
1306
|
+
table.appendChild(thead);
|
|
1307
|
+
const tbody = el('tbody', {});
|
|
1308
|
+
for (const p of byProject.slice(0, 20)) {
|
|
1309
|
+
const tr = el('tr', {});
|
|
1310
|
+
const name = p.project?.split('/').slice(-2).join('/') ?? '—';
|
|
1311
|
+
tr.appendChild(el('td', { title: p.project }, name));
|
|
1312
|
+
tr.appendChild(el('td', { cls: 'num' }, p.sessions));
|
|
1313
|
+
tr.appendChild(el('td', { cls: 'num' }, p.commits));
|
|
1314
|
+
tr.appendChild(el('td', { cls: 'num' }, fmtK(p.tokensIn + p.tokensOut)));
|
|
1315
|
+
tr.appendChild(el('td', { cls: 'num' }, `$${fmt(p.estCostUsd)}`));
|
|
1316
|
+
tbody.appendChild(tr);
|
|
1317
|
+
}
|
|
1318
|
+
table.appendChild(tbody);
|
|
1319
|
+
wrap.appendChild(table);
|
|
1320
|
+
return wrap;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// ---------------------------------------------------------------------------
|
|
1324
|
+
// Genome view
|
|
1325
|
+
// ---------------------------------------------------------------------------
|
|
1326
|
+
|
|
1327
|
+
async function loadGenome(query = '') {
|
|
1328
|
+
showLoading('genome');
|
|
1329
|
+
try {
|
|
1330
|
+
const url = query ? `/api/genome?q=${encodeURIComponent(query)}` : '/api/genome';
|
|
1331
|
+
state.genome = await apiFetch(url);
|
|
1332
|
+
state.genomeQuery = query;
|
|
1333
|
+
renderGenome();
|
|
1334
|
+
} catch (err) {
|
|
1335
|
+
showError('genome', err.message);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function renderGenome() {
|
|
1340
|
+
if (state.activeView !== 'genome') return;
|
|
1341
|
+
const main = getMain();
|
|
1342
|
+
if (!main) return;
|
|
1343
|
+
main.innerHTML = '';
|
|
1344
|
+
|
|
1345
|
+
const section = el('section', { cls: 'view-section' });
|
|
1346
|
+
section.appendChild(el('div', { cls: 'view-header' },
|
|
1347
|
+
el('h1', { cls: 'view-title' }, 'Genome'),
|
|
1348
|
+
el('span', { cls: 'view-subtitle' }, 'Shared memory / cross-project notes')
|
|
1349
|
+
));
|
|
1350
|
+
|
|
1351
|
+
// Search box
|
|
1352
|
+
const searchRow = el('div', { cls: 'search-row' });
|
|
1353
|
+
const input = el('input', {
|
|
1354
|
+
type: 'text',
|
|
1355
|
+
cls: 'search-input',
|
|
1356
|
+
placeholder: 'Search genome…',
|
|
1357
|
+
value: state.genomeQuery
|
|
1358
|
+
});
|
|
1359
|
+
const searchBtn = el('button', { cls: 'btn', onClick: () => loadGenome(input.value.trim()) }, 'Search');
|
|
1360
|
+
input.addEventListener('keydown', (e) => {
|
|
1361
|
+
if (e.key === 'Enter') loadGenome(input.value.trim());
|
|
1362
|
+
});
|
|
1363
|
+
searchRow.appendChild(input);
|
|
1364
|
+
searchRow.appendChild(searchBtn);
|
|
1365
|
+
if (state.genomeQuery) {
|
|
1366
|
+
const clearBtn = el('button', { cls: 'btn btn-ghost', onClick: () => loadGenome('') }, 'Clear');
|
|
1367
|
+
searchRow.appendChild(clearBtn);
|
|
1368
|
+
}
|
|
1369
|
+
section.appendChild(searchRow);
|
|
1370
|
+
|
|
1371
|
+
// Results
|
|
1372
|
+
const entries = state.genome;
|
|
1373
|
+
const isRecall = Array.isArray(entries) && entries.length > 0 && 'entry' in entries[0];
|
|
1374
|
+
|
|
1375
|
+
// Normalize: recall hits wrap { entry, score } vs direct GenomeEntry[]
|
|
1376
|
+
const items = isRecall
|
|
1377
|
+
? entries.map((h) => ({ ...h.entry, _score: h.score, _method: h.method }))
|
|
1378
|
+
: entries;
|
|
1379
|
+
|
|
1380
|
+
if (items.length === 0) {
|
|
1381
|
+
section.appendChild(el('div', { cls: 'empty-state' },
|
|
1382
|
+
el('p', {}, state.genomeQuery ? `No results for "${state.genomeQuery}".` : 'No genome entries yet.'),
|
|
1383
|
+
el('p', { cls: 'hint' }, 'Use `ashlr learn "text"` to add entries.')
|
|
1384
|
+
));
|
|
1385
|
+
} else {
|
|
1386
|
+
section.appendChild(el('div', { cls: 'view-subtitle' }, `${items.length} entr${items.length !== 1 ? 'ies' : 'y'}`));
|
|
1387
|
+
const list = el('div', { cls: 'genome-list' });
|
|
1388
|
+
for (const item of items) {
|
|
1389
|
+
list.appendChild(genomeCard(item));
|
|
1390
|
+
}
|
|
1391
|
+
section.appendChild(list);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
main.appendChild(section);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function genomeCard(entry) {
|
|
1398
|
+
const card = el('div', { cls: 'genome-card' });
|
|
1399
|
+
|
|
1400
|
+
const header = el('div', { cls: 'genome-card-header' });
|
|
1401
|
+
header.appendChild(el('span', { cls: 'genome-title' }, entry.title || '(untitled)'));
|
|
1402
|
+
const meta = el('div', { cls: 'genome-meta' });
|
|
1403
|
+
if (entry.source) meta.appendChild(el('span', { cls: 'badge badge-source' }, entry.source));
|
|
1404
|
+
if (entry.project) meta.appendChild(el('span', { cls: 'genome-project', title: entry.project },
|
|
1405
|
+
entry.project.split('/').pop()));
|
|
1406
|
+
if (entry._score != null) meta.appendChild(el('span', { cls: 'badge' }, `score: ${entry._score.toFixed(2)}`));
|
|
1407
|
+
meta.appendChild(el('span', { cls: 'ts' }, fmtRelative(entry.ts)));
|
|
1408
|
+
header.appendChild(meta);
|
|
1409
|
+
card.appendChild(header);
|
|
1410
|
+
|
|
1411
|
+
card.appendChild(el('p', { cls: 'genome-text' }, entry.text));
|
|
1412
|
+
|
|
1413
|
+
if (entry.tags && entry.tags.length > 0) {
|
|
1414
|
+
const tagRow = el('div', { cls: 'genome-tags' });
|
|
1415
|
+
for (const tag of entry.tags) {
|
|
1416
|
+
tagRow.appendChild(el('span', { cls: 'badge badge-tag' }, tag));
|
|
1417
|
+
}
|
|
1418
|
+
card.appendChild(tagRow);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
return card;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// ---------------------------------------------------------------------------
|
|
1425
|
+
// Portfolio view (M29) — READ-ONLY org-level summary.
|
|
1426
|
+
//
|
|
1427
|
+
// Reads GET /api/portfolio (a read-only projection of buildSnapshot().portfolio)
|
|
1428
|
+
// and renders health / in-flight goals / top backlog / cost+forecast /
|
|
1429
|
+
// effectiveness / today deltas. No mutation controls — there is nothing to
|
|
1430
|
+
// approve, apply, or dispatch from this view. Degrades to an empty-state card
|
|
1431
|
+
// when the portfolio section is null (older producer / empty enrollment).
|
|
1432
|
+
// ---------------------------------------------------------------------------
|
|
1433
|
+
|
|
1434
|
+
async function loadPortfolio() {
|
|
1435
|
+
showLoading('portfolio');
|
|
1436
|
+
try {
|
|
1437
|
+
state.portfolio = await apiFetch('/api/portfolio');
|
|
1438
|
+
renderPortfolio();
|
|
1439
|
+
} catch (err) {
|
|
1440
|
+
showError('portfolio', err.message);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function renderPortfolio() {
|
|
1445
|
+
if (state.activeView !== 'portfolio') return;
|
|
1446
|
+
const main = getMain();
|
|
1447
|
+
if (!main) return;
|
|
1448
|
+
main.innerHTML = '';
|
|
1449
|
+
|
|
1450
|
+
const section = el('section', { cls: 'view-section' });
|
|
1451
|
+
section.appendChild(el('div', { cls: 'view-header' },
|
|
1452
|
+
el('h1', { cls: 'view-title' }, 'Portfolio'),
|
|
1453
|
+
el('span', { cls: 'view-subtitle' }, 'Org-level health, goals, cost — read-only')
|
|
1454
|
+
));
|
|
1455
|
+
|
|
1456
|
+
const p = state.portfolio;
|
|
1457
|
+
if (!p) {
|
|
1458
|
+
section.appendChild(el('div', { cls: 'empty-state' },
|
|
1459
|
+
el('p', {}, 'No portfolio data yet.'),
|
|
1460
|
+
el('p', { cls: 'hint' }, 'Enroll repos and run `ashlr health` / `ashlr goals` to populate this view.')
|
|
1461
|
+
));
|
|
1462
|
+
main.appendChild(section);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// ── Health ────────────────────────────────────────────────────────────
|
|
1467
|
+
const h = p.health || { reposScored: 0, averageScore: 0, averageGrade: 'F', worstRepos: [] };
|
|
1468
|
+
const healthCard = el('div', { cls: 'card' });
|
|
1469
|
+
healthCard.appendChild(el('h2', { cls: 'card-title' }, 'Health'));
|
|
1470
|
+
if (h.reposScored > 0) {
|
|
1471
|
+
healthCard.appendChild(infoGrid([
|
|
1472
|
+
['Repos scored', h.reposScored],
|
|
1473
|
+
['Avg score', Math.round(h.averageScore)],
|
|
1474
|
+
['Avg grade', h.averageGrade],
|
|
1475
|
+
]));
|
|
1476
|
+
if (Array.isArray(h.worstRepos) && h.worstRepos.length > 0) {
|
|
1477
|
+
const ul = el('ul', { cls: 'portfolio-worst' });
|
|
1478
|
+
for (const w of h.worstRepos) {
|
|
1479
|
+
ul.appendChild(el('li', {},
|
|
1480
|
+
el('span', { cls: 'portfolio-repo', title: w.repo }, (w.repo || '').split('/').filter(Boolean).pop() || w.repo),
|
|
1481
|
+
el('span', { cls: `badge grade-${w.grade}` }, `${w.grade} (${Math.round(w.score)})`)
|
|
1482
|
+
));
|
|
1483
|
+
}
|
|
1484
|
+
healthCard.appendChild(el('p', { cls: 'hint' }, 'Needs attention:'));
|
|
1485
|
+
healthCard.appendChild(ul);
|
|
1486
|
+
}
|
|
1487
|
+
} else {
|
|
1488
|
+
healthCard.appendChild(el('p', { cls: 'hint' }, 'No enrolled repos scored.'));
|
|
1489
|
+
}
|
|
1490
|
+
section.appendChild(healthCard);
|
|
1491
|
+
|
|
1492
|
+
// ── Goals in flight ─────────────────────────────────────────────────────
|
|
1493
|
+
const goals = Array.isArray(p.goalsInFlight) ? p.goalsInFlight : [];
|
|
1494
|
+
const goalsCard = el('div', { cls: 'card' });
|
|
1495
|
+
goalsCard.appendChild(el('h2', { cls: 'card-title' }, `Goals in flight (${goals.length})`));
|
|
1496
|
+
if (goals.length === 0) {
|
|
1497
|
+
goalsCard.appendChild(el('p', { cls: 'hint' }, 'None active.'));
|
|
1498
|
+
} else {
|
|
1499
|
+
const list = el('div', { cls: 'portfolio-goals' });
|
|
1500
|
+
for (const g of goals) {
|
|
1501
|
+
const pct = Math.round(Math.max(0, Math.min(1, g.fractionDone || 0)) * 100);
|
|
1502
|
+
const row = el('div', { cls: 'portfolio-goal' });
|
|
1503
|
+
row.appendChild(el('div', { cls: 'portfolio-goal__obj', title: g.objective },
|
|
1504
|
+
truncate(g.objective, 70)));
|
|
1505
|
+
row.appendChild(el('div', { cls: 'portfolio-goal__meta' }, `${pct}% · ${g.proposed}/${g.totalMilestones} proposed`));
|
|
1506
|
+
if (g.nextActionable) {
|
|
1507
|
+
row.appendChild(el('div', { cls: 'hint' }, `next: ${truncate(g.nextActionable, 60)}`));
|
|
1508
|
+
}
|
|
1509
|
+
list.appendChild(row);
|
|
1510
|
+
}
|
|
1511
|
+
goalsCard.appendChild(list);
|
|
1512
|
+
}
|
|
1513
|
+
section.appendChild(goalsCard);
|
|
1514
|
+
|
|
1515
|
+
// ── Top backlog ───────────────────────────────────────────────────────
|
|
1516
|
+
const backlog = Array.isArray(p.backlogTop) ? p.backlogTop : [];
|
|
1517
|
+
const backlogCard = el('div', { cls: 'card' });
|
|
1518
|
+
backlogCard.appendChild(el('h2', { cls: 'card-title' }, 'Top backlog'));
|
|
1519
|
+
if (backlog.length === 0) {
|
|
1520
|
+
backlogCard.appendChild(el('p', { cls: 'hint' }, 'Empty.'));
|
|
1521
|
+
} else {
|
|
1522
|
+
const ul = el('ul', { cls: 'portfolio-backlog' });
|
|
1523
|
+
for (const item of backlog) {
|
|
1524
|
+
ul.appendChild(el('li', {},
|
|
1525
|
+
el('span', { cls: 'badge' }, String(Math.round(item.score))),
|
|
1526
|
+
el('span', { cls: 'portfolio-backlog__title', title: item.title }, truncate(item.title, 80))
|
|
1527
|
+
));
|
|
1528
|
+
}
|
|
1529
|
+
backlogCard.appendChild(ul);
|
|
1530
|
+
}
|
|
1531
|
+
section.appendChild(backlogCard);
|
|
1532
|
+
|
|
1533
|
+
// ── Cost + forecast ─────────────────────────────────────────────────────
|
|
1534
|
+
const c = p.cost || { window: '7d', spentUsd: 0, localSavingsUsd: 0, projectedMonthlyUsd: 0 };
|
|
1535
|
+
const costCard = el('div', { cls: 'card' });
|
|
1536
|
+
costCard.appendChild(el('h2', { cls: 'card-title' }, `Cost (${c.window})`));
|
|
1537
|
+
costCard.appendChild(infoGrid([
|
|
1538
|
+
['Spent', `$${(c.spentUsd || 0).toFixed(2)}`],
|
|
1539
|
+
['Saved (local)', `$${(c.localSavingsUsd || 0).toFixed(2)}`],
|
|
1540
|
+
['Projected / mo', `$${(c.projectedMonthlyUsd || 0).toFixed(2)}`],
|
|
1541
|
+
]));
|
|
1542
|
+
section.appendChild(costCard);
|
|
1543
|
+
|
|
1544
|
+
// ── Effectiveness ───────────────────────────────────────────────────────
|
|
1545
|
+
if (p.effectiveness) {
|
|
1546
|
+
const eff = el('div', { cls: 'card' });
|
|
1547
|
+
eff.appendChild(el('h2', { cls: 'card-title' }, 'Effectiveness'));
|
|
1548
|
+
eff.appendChild(el('p', {}, p.effectiveness.headline || ''));
|
|
1549
|
+
section.appendChild(eff);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// ── Today (day-over-day deltas) ──────────────────────────────────────────
|
|
1553
|
+
const t = p.today || { previousAt: null };
|
|
1554
|
+
const todayCard = el('div', { cls: 'card' });
|
|
1555
|
+
todayCard.appendChild(el('h2', { cls: 'card-title' }, 'Today'));
|
|
1556
|
+
if (t.previousAt === null || t.previousAt === undefined) {
|
|
1557
|
+
todayCard.appendChild(el('p', { cls: 'hint' }, 'No prior digest to compare against yet.'));
|
|
1558
|
+
} else {
|
|
1559
|
+
todayCard.appendChild(infoGrid([
|
|
1560
|
+
['Pending Δ', fmtDelta(t.pendingProposalsDelta)],
|
|
1561
|
+
['Dirty Δ', fmtDelta(t.dirtyReposDelta)],
|
|
1562
|
+
['Spend Δ', fmtDelta(t.spendUsdDelta, '$')],
|
|
1563
|
+
['Health Δ', fmtDelta(t.healthScoreDelta)],
|
|
1564
|
+
['Goals Δ', fmtDelta(t.goalsInFlightDelta)],
|
|
1565
|
+
]));
|
|
1566
|
+
}
|
|
1567
|
+
section.appendChild(todayCard);
|
|
1568
|
+
|
|
1569
|
+
main.appendChild(section);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// ---------------------------------------------------------------------------
|
|
1573
|
+
// M32: Inbox view
|
|
1574
|
+
// ---------------------------------------------------------------------------
|
|
1575
|
+
|
|
1576
|
+
async function loadInbox() {
|
|
1577
|
+
showLoading('inbox');
|
|
1578
|
+
try {
|
|
1579
|
+
const data = await apiFetch('/api/inbox');
|
|
1580
|
+
state.inbox = data;
|
|
1581
|
+
state.inboxBadge = data.pending ?? 0;
|
|
1582
|
+
updateInboxBadge();
|
|
1583
|
+
renderInbox();
|
|
1584
|
+
} catch (err) {
|
|
1585
|
+
showError('inbox', err.message);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
function renderInbox() {
|
|
1590
|
+
if (state.activeView !== 'inbox') return;
|
|
1591
|
+
const main = getMain();
|
|
1592
|
+
if (!main) return;
|
|
1593
|
+
main.innerHTML = '';
|
|
1594
|
+
|
|
1595
|
+
const section = el('section', { cls: 'view-section' });
|
|
1596
|
+
section.appendChild(el('div', { cls: 'view-header' },
|
|
1597
|
+
el('h1', { cls: 'view-title' }, 'Inbox'),
|
|
1598
|
+
el('span', { cls: 'view-subtitle' }, `${state.inbox.pending ?? 0} pending`)
|
|
1599
|
+
));
|
|
1600
|
+
|
|
1601
|
+
const proposals = state.inbox.proposals ?? [];
|
|
1602
|
+
|
|
1603
|
+
if (state.inboxDetail) {
|
|
1604
|
+
// Detail pane
|
|
1605
|
+
section.appendChild(buildInboxDetail(state.inboxDetail));
|
|
1606
|
+
main.appendChild(section);
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
if (proposals.length === 0) {
|
|
1611
|
+
section.appendChild(el('div', { cls: 'empty-state' },
|
|
1612
|
+
el('p', {}, 'No pending proposals.'),
|
|
1613
|
+
el('p', { cls: 'hint' }, 'Proposals appear here when the daemon queues work for review.')
|
|
1614
|
+
));
|
|
1615
|
+
} else {
|
|
1616
|
+
const list = el('div', { cls: 'inbox-list' });
|
|
1617
|
+
for (const p of proposals) {
|
|
1618
|
+
list.appendChild(buildInboxRow(p));
|
|
1619
|
+
}
|
|
1620
|
+
section.appendChild(list);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
main.appendChild(section);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function buildInboxRow(p) {
|
|
1627
|
+
const row = el('div', { cls: 'inbox-row', style: 'cursor:pointer' });
|
|
1628
|
+
const repoBase = (p.repo ?? '').split('/').filter(Boolean).pop() ?? p.repo ?? '—';
|
|
1629
|
+
|
|
1630
|
+
row.appendChild(el('div', { cls: 'inbox-row__main' },
|
|
1631
|
+
el('span', { cls: 'inbox-row__title', title: p.title }, truncate(p.title ?? '(untitled)', 80)),
|
|
1632
|
+
el('div', { cls: 'inbox-row__meta' },
|
|
1633
|
+
el('span', { cls: 'badge badge-kind' }, p.kind ?? 'proposal'),
|
|
1634
|
+
el('span', { cls: 'inbox-row__repo' }, repoBase),
|
|
1635
|
+
el('span', { cls: 'ts' }, fmtRelative(p.createdAt))
|
|
1636
|
+
)
|
|
1637
|
+
));
|
|
1638
|
+
|
|
1639
|
+
row.addEventListener('click', async () => {
|
|
1640
|
+
try {
|
|
1641
|
+
const full = await apiFetch(`/api/inbox/${encodeURIComponent(p.id)}`);
|
|
1642
|
+
state.inboxDetail = full;
|
|
1643
|
+
renderInbox();
|
|
1644
|
+
} catch (err) {
|
|
1645
|
+
const main_ = getMain();
|
|
1646
|
+
if (main_) {
|
|
1647
|
+
const errEl = el('div', { cls: 'inline-error' }, `Failed to load proposal: ${err.message}`);
|
|
1648
|
+
main_.appendChild(errEl);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
return row;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function buildInboxDetail(p) {
|
|
1657
|
+
const snap = state.snapshot;
|
|
1658
|
+
const dispatchEnabled = snap ? snap.dispatchEnabled === true : false;
|
|
1659
|
+
const token = getToken();
|
|
1660
|
+
const canAct = dispatchEnabled && Boolean(token);
|
|
1661
|
+
|
|
1662
|
+
let disabledReason = '';
|
|
1663
|
+
if (!dispatchEnabled) disabledReason = 'Start the server with --allow-dispatch to approve or reject.';
|
|
1664
|
+
else if (!token) disabledReason = 'Enter your session token (gear icon) to approve or reject.';
|
|
1665
|
+
|
|
1666
|
+
const detail = el('div', { cls: 'inbox-detail' });
|
|
1667
|
+
|
|
1668
|
+
// Back button
|
|
1669
|
+
detail.appendChild(el('button', {
|
|
1670
|
+
cls: 'btn back-btn',
|
|
1671
|
+
type: 'button',
|
|
1672
|
+
onClick: () => { state.inboxDetail = null; renderInbox(); },
|
|
1673
|
+
}, '← Back'));
|
|
1674
|
+
|
|
1675
|
+
const repoBase = (p.repo ?? '').split('/').filter(Boolean).pop() ?? p.repo ?? '—';
|
|
1676
|
+
|
|
1677
|
+
// Summary card
|
|
1678
|
+
const summary = el('div', { cls: 'inbox-detail__summary card' });
|
|
1679
|
+
summary.appendChild(el('h2', { cls: 'card-title' }, p.title ?? '(untitled)'));
|
|
1680
|
+
summary.appendChild(infoGrid([
|
|
1681
|
+
['Kind', p.kind ?? '—'],
|
|
1682
|
+
['Repo', repoBase],
|
|
1683
|
+
['Origin', p.origin ?? '—'],
|
|
1684
|
+
['Status', p.status ?? '—'],
|
|
1685
|
+
['Created', fmtDate(p.createdAt)],
|
|
1686
|
+
]));
|
|
1687
|
+
detail.appendChild(summary);
|
|
1688
|
+
|
|
1689
|
+
// Diff
|
|
1690
|
+
if (p.diff) {
|
|
1691
|
+
const diffSection = el('div', { cls: 'inbox-detail__diff-wrap' });
|
|
1692
|
+
diffSection.appendChild(el('h3', { cls: 'section-heading' }, 'Diff'));
|
|
1693
|
+
const pre = el('pre', { cls: 'inbox-diff' });
|
|
1694
|
+
pre.innerHTML = escapeHtml(p.diff);
|
|
1695
|
+
diffSection.appendChild(pre);
|
|
1696
|
+
detail.appendChild(diffSection);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// Approve / Reject buttons + result
|
|
1700
|
+
const actionsDiv = el('div', { cls: 'inbox-detail__actions' });
|
|
1701
|
+
const resultLine = el('div', { cls: 'inbox-detail__result', 'aria-live': 'polite' });
|
|
1702
|
+
|
|
1703
|
+
const approveBtn = el('button', {
|
|
1704
|
+
cls: 'btn btn-primary',
|
|
1705
|
+
type: 'button',
|
|
1706
|
+
...(canAct ? {} : { disabled: 'disabled' }),
|
|
1707
|
+
title: canAct ? 'Approve and apply this proposal' : disabledReason,
|
|
1708
|
+
}, 'Approve');
|
|
1709
|
+
|
|
1710
|
+
const rejectBtn = el('button', {
|
|
1711
|
+
cls: 'btn btn-danger',
|
|
1712
|
+
type: 'button',
|
|
1713
|
+
...(canAct ? {} : { disabled: 'disabled' }),
|
|
1714
|
+
title: canAct ? 'Reject this proposal' : disabledReason,
|
|
1715
|
+
}, 'Reject');
|
|
1716
|
+
|
|
1717
|
+
approveBtn.addEventListener('click', async () => {
|
|
1718
|
+
if (!canAct) return;
|
|
1719
|
+
approveBtn.disabled = true;
|
|
1720
|
+
rejectBtn.disabled = true;
|
|
1721
|
+
resultLine.textContent = 'Approving…';
|
|
1722
|
+
try {
|
|
1723
|
+
const result = await apiPost(`/api/inbox/${encodeURIComponent(p.id)}/approve`, token);
|
|
1724
|
+
resultLine.textContent = result.detail ? `Applied: ${result.detail}` : (result.ok ? 'Approved and applied.' : 'Apply returned ok:false');
|
|
1725
|
+
resultLine.className = 'inbox-detail__result inbox-detail__result--ok';
|
|
1726
|
+
} catch (err) {
|
|
1727
|
+
resultLine.textContent = `Error: ${err.message}`;
|
|
1728
|
+
resultLine.className = 'inbox-detail__result inbox-detail__result--err';
|
|
1729
|
+
approveBtn.disabled = false;
|
|
1730
|
+
rejectBtn.disabled = false;
|
|
1731
|
+
}
|
|
1732
|
+
// Refresh inbox list after action
|
|
1733
|
+
state.inboxDetail = null;
|
|
1734
|
+
await loadInbox();
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
rejectBtn.addEventListener('click', async () => {
|
|
1738
|
+
if (!canAct) return;
|
|
1739
|
+
approveBtn.disabled = true;
|
|
1740
|
+
rejectBtn.disabled = true;
|
|
1741
|
+
resultLine.textContent = 'Rejecting…';
|
|
1742
|
+
try {
|
|
1743
|
+
await apiPost(`/api/inbox/${encodeURIComponent(p.id)}/reject`, token);
|
|
1744
|
+
resultLine.textContent = 'Rejected.';
|
|
1745
|
+
resultLine.className = 'inbox-detail__result inbox-detail__result--ok';
|
|
1746
|
+
} catch (err) {
|
|
1747
|
+
resultLine.textContent = `Error: ${err.message}`;
|
|
1748
|
+
resultLine.className = 'inbox-detail__result inbox-detail__result--err';
|
|
1749
|
+
approveBtn.disabled = false;
|
|
1750
|
+
rejectBtn.disabled = false;
|
|
1751
|
+
}
|
|
1752
|
+
state.inboxDetail = null;
|
|
1753
|
+
await loadInbox();
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
actionsDiv.appendChild(approveBtn);
|
|
1757
|
+
actionsDiv.appendChild(rejectBtn);
|
|
1758
|
+
if (!canAct && disabledReason) {
|
|
1759
|
+
actionsDiv.appendChild(el('span', { cls: 'dispatch-panel__note' }, disabledReason));
|
|
1760
|
+
}
|
|
1761
|
+
detail.appendChild(actionsDiv);
|
|
1762
|
+
detail.appendChild(resultLine);
|
|
1763
|
+
|
|
1764
|
+
return detail;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// ---------------------------------------------------------------------------
|
|
1768
|
+
// M32: Daemon view
|
|
1769
|
+
// ---------------------------------------------------------------------------
|
|
1770
|
+
|
|
1771
|
+
async function loadDaemon() {
|
|
1772
|
+
showLoading('daemon');
|
|
1773
|
+
try {
|
|
1774
|
+
state.daemon = await apiFetch('/api/daemon');
|
|
1775
|
+
renderDaemon();
|
|
1776
|
+
} catch (err) {
|
|
1777
|
+
showError('daemon', err.message);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function renderDaemon() {
|
|
1782
|
+
if (state.activeView !== 'daemon') return;
|
|
1783
|
+
const main = getMain();
|
|
1784
|
+
if (!main) return;
|
|
1785
|
+
main.innerHTML = '';
|
|
1786
|
+
|
|
1787
|
+
const section = el('section', { cls: 'view-section' });
|
|
1788
|
+
section.appendChild(el('div', { cls: 'view-header' },
|
|
1789
|
+
el('h1', { cls: 'view-title' }, 'Daemon'),
|
|
1790
|
+
el('span', { cls: 'view-subtitle' }, 'Background automation state')
|
|
1791
|
+
));
|
|
1792
|
+
|
|
1793
|
+
const d = state.daemon;
|
|
1794
|
+
if (!d) {
|
|
1795
|
+
section.appendChild(el('div', { cls: 'empty-state' },
|
|
1796
|
+
el('p', {}, 'Daemon state unavailable.'),
|
|
1797
|
+
el('p', { cls: 'hint' }, 'Start the daemon with `ashlr daemon start`.')
|
|
1798
|
+
));
|
|
1799
|
+
main.appendChild(section);
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
const card = el('div', { cls: 'daemon-card card' });
|
|
1804
|
+
|
|
1805
|
+
// Running indicator
|
|
1806
|
+
const runningDot = el('span', {
|
|
1807
|
+
cls: d.running ? 'daemon-dot daemon-dot--running' : 'daemon-dot',
|
|
1808
|
+
title: d.running ? 'Running' : 'Stopped',
|
|
1809
|
+
});
|
|
1810
|
+
const statusRow = el('div', { cls: 'daemon-card__status' },
|
|
1811
|
+
runningDot,
|
|
1812
|
+
el('span', { cls: d.running ? 'daemon-status--on' : 'daemon-status--off' },
|
|
1813
|
+
d.running ? 'Running' : 'Stopped')
|
|
1814
|
+
);
|
|
1815
|
+
card.appendChild(statusRow);
|
|
1816
|
+
|
|
1817
|
+
// Info grid
|
|
1818
|
+
const pairs = [
|
|
1819
|
+
['Last tick', d.lastTick ? fmtRelative(d.lastTick) : '—'],
|
|
1820
|
+
['Today spend', d.todaySpendUsd != null ? `$${d.todaySpendUsd.toFixed(4)}` : '—'],
|
|
1821
|
+
['Spend cap', d.spendCapUsd != null ? `$${d.spendCapUsd.toFixed(2)}` : '—'],
|
|
1822
|
+
['Pending proposals', d.pendingProposals ?? state.inboxBadge ?? '—'],
|
|
1823
|
+
];
|
|
1824
|
+
card.appendChild(infoGrid(pairs));
|
|
1825
|
+
|
|
1826
|
+
// Spend vs cap mini bar
|
|
1827
|
+
if (d.spendCapUsd != null && d.todaySpendUsd != null) {
|
|
1828
|
+
const pct = Math.min(100, (d.todaySpendUsd / d.spendCapUsd) * 100);
|
|
1829
|
+
const level = pct >= 90 ? 'var(--status-failed)' : pct >= 70 ? 'var(--status-aborted)' : 'var(--status-done)';
|
|
1830
|
+
const track = el('div', { cls: 'daemon-spend-track' });
|
|
1831
|
+
track.appendChild(el('div', {
|
|
1832
|
+
cls: 'daemon-spend-fill',
|
|
1833
|
+
style: `width:${pct.toFixed(1)}%;background:${level}`,
|
|
1834
|
+
}));
|
|
1835
|
+
const spendWrap = el('div', { cls: 'daemon-spend-wrap' },
|
|
1836
|
+
el('span', { cls: 'daemon-spend-label' }, `Spend today: ${pct.toFixed(1)}% of cap`),
|
|
1837
|
+
track
|
|
1838
|
+
);
|
|
1839
|
+
card.appendChild(spendWrap);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
section.appendChild(card);
|
|
1843
|
+
main.appendChild(section);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/** Format a signed delta for the portfolio "today" block. null → em-dash. */
|
|
1847
|
+
function fmtDelta(n, prefix = '') {
|
|
1848
|
+
if (n === null || n === undefined || typeof n !== 'number' || !isFinite(n)) return '—';
|
|
1849
|
+
const sign = n > 0 ? '+' : n < 0 ? '-' : '';
|
|
1850
|
+
const mag = prefix === '$' ? Math.abs(n).toFixed(2) : String(Math.abs(n));
|
|
1851
|
+
return `${sign}${prefix}${mag}`;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
// ---------------------------------------------------------------------------
|
|
1855
|
+
// Shared UI helpers
|
|
1856
|
+
// ---------------------------------------------------------------------------
|
|
1857
|
+
|
|
1858
|
+
function infoGrid(pairs) {
|
|
1859
|
+
const grid = el('dl', { cls: 'info-grid' });
|
|
1860
|
+
for (const [label, value] of pairs) {
|
|
1861
|
+
grid.appendChild(el('dt', {}, label));
|
|
1862
|
+
grid.appendChild(el('dd', { title: String(value) }, String(value ?? '—')));
|
|
1863
|
+
}
|
|
1864
|
+
return grid;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function truncate(s, max) {
|
|
1868
|
+
if (!s) return '';
|
|
1869
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// ---------------------------------------------------------------------------
|
|
1873
|
+
// SSE dot indicator
|
|
1874
|
+
// ---------------------------------------------------------------------------
|
|
1875
|
+
|
|
1876
|
+
function updateSseDot(connected) {
|
|
1877
|
+
const dot = document.getElementById('sse-dot');
|
|
1878
|
+
if (!dot) return;
|
|
1879
|
+
dot.classList.toggle('connected', connected);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// ---------------------------------------------------------------------------
|
|
1883
|
+
// Boot
|
|
1884
|
+
// ---------------------------------------------------------------------------
|
|
1885
|
+
|
|
1886
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1887
|
+
renderShell();
|
|
1888
|
+
|
|
1889
|
+
// Connect SSE first so events can patch data while views load
|
|
1890
|
+
connectSSE();
|
|
1891
|
+
|
|
1892
|
+
// Wire SSE dot
|
|
1893
|
+
if (state.eventSource) {
|
|
1894
|
+
state.eventSource.addEventListener('open', () => updateSseDot(true));
|
|
1895
|
+
state.eventSource.addEventListener('error', () => updateSseDot(false));
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// Hash routing
|
|
1899
|
+
window.addEventListener('hashchange', onHashChange);
|
|
1900
|
+
|
|
1901
|
+
// Initial view
|
|
1902
|
+
onHashChange();
|
|
1903
|
+
|
|
1904
|
+
// M32: initialise token indicator after shell is rendered
|
|
1905
|
+
updateTokenIndicator();
|
|
1906
|
+
});
|