@songsid/agend 2.3.0-beta.9 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -8
- package/README.zh-TW.md +16 -8
- package/dist/adapter-world.js.map +1 -1
- package/dist/agend-home.d.ts +5 -0
- package/dist/agend-home.js +7 -0
- package/dist/agend-home.js.map +1 -0
- package/dist/agent-cli-instructions.md +7 -1
- package/dist/agent-cli.js +41 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +9 -0
- package/dist/agent-endpoint.js +23 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-file-read.js +1 -1
- package/dist/agent-file-read.js.map +1 -1
- package/dist/agent-whats-new.d.ts +16 -0
- package/dist/agent-whats-new.js +53 -12
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/apply-job.js.map +1 -1
- package/dist/atomic-write.js.map +1 -1
- package/dist/auth-api.d.ts +12 -0
- package/dist/auth-api.js +77 -22
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/authority-state.d.ts +4 -0
- package/dist/authority/authority-state.js +17 -8
- package/dist/authority/authority-state.js.map +1 -1
- package/dist/authority/session-authorizations.js.map +1 -1
- package/dist/authority/web-authority.d.ts +16 -8
- package/dist/authority/web-authority.js +19 -5
- package/dist/authority/web-authority.js.map +1 -1
- package/dist/backend/antigravity.d.ts +2 -2
- package/dist/backend/antigravity.js +2 -1
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.js +13 -4
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex-metadata.d.ts +8 -0
- package/dist/backend/codex-metadata.js +74 -17
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/backend/codex-package.d.ts +30 -0
- package/dist/backend/codex-package.js +133 -0
- package/dist/backend/codex-package.js.map +1 -0
- package/dist/backend/codex-session-lookup.js.map +1 -1
- package/dist/backend/codex.d.ts +2 -0
- package/dist/backend/codex.js +25 -5
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +2 -0
- package/dist/backend/credential-profile.js +2 -0
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/effort-metadata.js.map +1 -1
- package/dist/backend/grok.js +58 -3
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro-engine-ledger.js.map +1 -1
- package/dist/backend/kiro-kas-store.js.map +1 -1
- package/dist/backend/kiro-v2-store.js.map +1 -1
- package/dist/backend/kiro-v3-identity.js.map +1 -1
- package/dist/backend/kiro.d.ts +9 -1
- package/dist/backend/kiro.js +102 -1
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/launch-effort.js.map +1 -1
- package/dist/backend/mock.js.map +1 -1
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/opencode-permission.js +1 -1
- package/dist/backend/opencode-permission.js.map +1 -1
- package/dist/backend/opencode.js +2 -1
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/session-signals.js.map +1 -1
- package/dist/backend-availability.js.map +1 -1
- package/dist/backend-command.d.ts +2 -0
- package/dist/backend-command.js +3 -1
- package/dist/backend-command.js.map +1 -1
- package/dist/backend-outage.js.map +1 -1
- package/dist/binary-probe.js +1 -1
- package/dist/binary-probe.js.map +1 -1
- package/dist/bug-report-cli.d.ts +30 -0
- package/dist/bug-report-cli.js +139 -0
- package/dist/bug-report-cli.js.map +1 -0
- package/dist/bug-report.d.ts +47 -0
- package/dist/bug-report.js +137 -0
- package/dist/bug-report.js.map +1 -0
- package/dist/bundled-skills.d.ts +18 -0
- package/dist/bundled-skills.js +38 -0
- package/dist/bundled-skills.js.map +1 -0
- package/dist/cache-service.js +1 -1
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord-client-retirement.d.ts +5 -0
- package/dist/channel/adapters/discord-client-retirement.js +57 -0
- package/dist/channel/adapters/discord-client-retirement.js.map +1 -0
- package/dist/channel/adapters/discord.d.ts +4 -0
- package/dist/channel/adapters/discord.js +57 -12
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +6 -0
- package/dist/channel/adapters/telegram.js +54 -14
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-server.js +21 -4
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.d.ts +3 -1
- package/dist/channel/mcp-tools.js +41 -4
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/message-queue.js.map +1 -1
- package/dist/channel/sticker-download.js.map +1 -1
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/child-process.d.ts +21 -0
- package/dist/child-process.js +54 -0
- package/dist/child-process.js.map +1 -0
- package/dist/classic-channel-manager.js +3 -2
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli-entry.js.map +1 -1
- package/dist/cli-flags.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +443 -311
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +1 -1
- package/dist/command-table.js +1 -1
- package/dist/command-table.js.map +1 -1
- package/dist/compact-watch.js.map +1 -1
- package/dist/completion-install.js.map +1 -1
- package/dist/completion.js.map +1 -1
- package/dist/config-validator.d.ts +6 -0
- package/dist/config-validator.js +81 -8
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +15 -3
- package/dist/config.js +41 -10
- package/dist/config.js.map +1 -1
- package/dist/connection-secrets.js.map +1 -1
- package/dist/context-guardian.js.map +1 -1
- package/dist/coredump-filter.js.map +1 -1
- package/dist/cpu-profile.js.map +1 -1
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/cross-instance-notice.d.ts +7 -0
- package/dist/cross-instance-notice.js +10 -0
- package/dist/cross-instance-notice.js.map +1 -1
- package/dist/daemon.d.ts +39 -1
- package/dist/daemon.js +234 -15
- package/dist/daemon.js.map +1 -1
- package/dist/daily-summary.js.map +1 -1
- package/dist/darwin-memory.js +1 -1
- package/dist/darwin-memory.js.map +1 -1
- package/dist/dashboard-live.d.ts +8 -0
- package/dist/dashboard-live.js +35 -0
- package/dist/dashboard-live.js.map +1 -0
- package/dist/dashboard-permissions.d.ts +69 -0
- package/dist/dashboard-permissions.js +138 -0
- package/dist/dashboard-permissions.js.map +1 -0
- package/dist/decision-list.js.map +1 -1
- package/dist/delivery-consumed-watch.js.map +1 -1
- package/dist/delivery-outbox.js +5 -0
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-outbox.test.js.map +1 -1
- package/dist/delivery-reconciliation.test.js.map +1 -1
- package/dist/diagnostic-redaction.d.ts +10 -0
- package/dist/diagnostic-redaction.js +167 -0
- package/dist/diagnostic-redaction.js.map +1 -0
- package/dist/doctor.d.ts +8 -0
- package/dist/doctor.js +52 -0
- package/dist/doctor.js.map +1 -1
- package/dist/e2e-isolation.js.map +1 -1
- package/dist/event-log.js.map +1 -1
- package/dist/export-import.js +7 -5
- package/dist/export-import.js.map +1 -1
- package/dist/fleet-control-audit.js.map +1 -1
- package/dist/fleet-label.js.map +1 -1
- package/dist/fleet-level-config.d.ts +1 -1
- package/dist/fleet-level-config.js +2 -0
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-lock.js +3 -2
- package/dist/fleet-lock.js.map +1 -1
- package/dist/fleet-manager.d.ts +249 -13
- package/dist/fleet-manager.js +1561 -292
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-process-handlers.d.ts +18 -0
- package/dist/fleet-process-handlers.js +70 -0
- package/dist/fleet-process-handlers.js.map +1 -0
- package/dist/fleet-startup.d.ts +14 -0
- package/dist/fleet-startup.js +30 -0
- package/dist/fleet-startup.js.map +1 -0
- package/dist/fleet-traffic.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +0 -20
- package/dist/forged-envelope-scan.js +3 -2
- package/dist/forged-envelope-scan.js.map +1 -1
- package/dist/full-restart.d.ts +5 -5
- package/dist/full-restart.js +5 -5
- package/dist/full-restart.js.map +1 -1
- package/dist/general-knowledge/skills/bug-report/SKILL.md +31 -0
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +52 -11
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +5 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +3 -1
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +8 -9
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
- package/dist/general-knowledge/skills/scheduling/SKILL.md +4 -0
- package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +321 -0
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +24 -4
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +553 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +154 -31
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
- package/dist/general-knowledge/steering/core-rules.md +1 -1
- package/dist/host-memory.js.map +1 -1
- package/dist/idle-gate-log.d.ts +19 -0
- package/dist/idle-gate-log.js +15 -0
- package/dist/idle-gate-log.js.map +1 -0
- package/dist/instance-config-impact.js +25 -4
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +10 -1
- package/dist/instance-lifecycle.js +86 -25
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +12 -6
- package/dist/instructions.js +19 -10
- package/dist/instructions.js.map +1 -1
- package/dist/kiro-compact-pane.js.map +1 -1
- package/dist/kiro-db-reader.js.map +1 -1
- package/dist/kiro-engine-status.js.map +1 -1
- package/dist/kiro-log-gate.js.map +1 -1
- package/dist/kiro-transcript-lane.js.map +1 -1
- package/dist/locale.js +428 -20
- package/dist/locale.js.map +1 -1
- package/dist/log-service.js.map +1 -1
- package/dist/log-steps.d.ts +1 -0
- package/dist/log-steps.js +15 -0
- package/dist/log-steps.js.map +1 -1
- package/dist/logger.js +3 -2
- package/dist/logger.js.map +1 -1
- package/dist/login-controller.d.ts +45 -0
- package/dist/login-controller.js +101 -5
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.js +1 -1
- package/dist/login-manager.js.map +1 -1
- package/dist/login-shell.js +3 -1
- package/dist/login-shell.js.map +1 -1
- package/dist/ls-attach-resolve.d.ts +96 -0
- package/dist/ls-attach-resolve.js +180 -0
- package/dist/ls-attach-resolve.js.map +1 -0
- package/dist/ls-rows.d.ts +12 -0
- package/dist/ls-rows.js +83 -4
- package/dist/ls-rows.js.map +1 -1
- package/dist/memory-pressure.js.map +1 -1
- package/dist/muse-usage-relay.js.map +1 -1
- package/dist/needs-you-hub.d.ts +98 -1
- package/dist/needs-you-hub.js +313 -44
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you-live.d.ts +5 -2
- package/dist/needs-you-live.js +6 -3
- package/dist/needs-you-live.js.map +1 -1
- package/dist/needs-you.d.ts +68 -3
- package/dist/needs-you.js +38 -4
- package/dist/needs-you.js.map +1 -1
- package/dist/network-family.js.map +1 -1
- package/dist/operator-control-paths.js +3 -4
- package/dist/operator-control-paths.js.map +1 -1
- package/dist/outbound-handlers.d.ts +3 -0
- package/dist/outbound-handlers.js +76 -19
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +106 -60
- package/dist/outbound-schemas.js +33 -10
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/outbox-open-error.js.map +1 -1
- package/dist/pane-context-cache.js.map +1 -1
- package/dist/paths.js.map +1 -1
- package/dist/private-dir.js.map +1 -1
- package/dist/probe-worker-pool.js.map +1 -1
- package/dist/profile-control.js.map +1 -1
- package/dist/prompt-file-ref.js.map +1 -1
- package/dist/provider-probe.js.map +1 -1
- package/dist/provider-secret-registry.d.ts +5 -3
- package/dist/provider-secret-registry.js +8 -2
- package/dist/provider-secret-registry.js.map +1 -1
- package/dist/public-link-auto-open.d.ts +32 -0
- package/dist/public-link-auto-open.js +86 -0
- package/dist/public-link-auto-open.js.map +1 -0
- package/dist/public-link-owner.d.ts +10 -0
- package/dist/public-link-owner.js +36 -0
- package/dist/public-link-owner.js.map +1 -0
- package/dist/public-link-progress.d.ts +12 -1
- package/dist/public-link-progress.js +5 -10
- package/dist/public-link-progress.js.map +1 -1
- package/dist/public-preview.js.map +1 -1
- package/dist/public-tunnel-config.d.ts +11 -0
- package/dist/public-tunnel-config.js +68 -0
- package/dist/public-tunnel-config.js.map +1 -0
- package/dist/public-web-gateway.d.ts +2 -0
- package/dist/public-web-gateway.js +19 -6
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +48 -3
- package/dist/public-web-link.js +177 -51
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +9 -0
- package/dist/quickstart-api.js +82 -0
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +5 -0
- package/dist/quickstart.js +40 -25
- package/dist/quickstart.js.map +1 -1
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/reply-dedup.js.map +1 -1
- package/dist/resource-report.js +1 -1
- package/dist/resource-report.js.map +1 -1
- package/dist/restart-guard.js.map +1 -1
- package/dist/restart-progress.d.ts +80 -1
- package/dist/restart-progress.js +306 -21
- package/dist/restart-progress.js.map +1 -1
- package/dist/rollout-index.js.map +1 -1
- package/dist/runtime-cpu-profile.js.map +1 -1
- package/dist/scheduler/db.d.ts +40 -13
- package/dist/scheduler/db.js +123 -89
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/scheduler/task-board-text.d.ts +26 -0
- package/dist/scheduler/task-board-text.js +140 -0
- package/dist/scheduler/task-board-text.js.map +1 -0
- package/dist/scheduler/task-board.d.ts +730 -0
- package/dist/scheduler/task-board.js +2179 -0
- package/dist/scheduler/task-board.js.map +1 -0
- package/dist/scheduler/types.d.ts +6 -1
- package/dist/sd-notify.js +2 -1
- package/dist/sd-notify.js.map +1 -1
- package/dist/search/search-docs.d.ts +58 -0
- package/dist/search/search-docs.js +63 -0
- package/dist/search/search-docs.js.map +1 -0
- package/dist/search/search-index.d.ts +141 -0
- package/dist/search/search-index.js +440 -0
- package/dist/search/search-index.js.map +1 -0
- package/dist/search/search-store.d.ts +122 -0
- package/dist/search/search-store.js +236 -0
- package/dist/search/search-store.js.map +1 -0
- package/dist/search/search-worker.d.ts +1 -0
- package/dist/search/search-worker.js +163 -0
- package/dist/search/search-worker.js.map +1 -0
- package/dist/secret-file.js.map +1 -1
- package/dist/secret-store.d.ts +2 -0
- package/dist/secret-store.js +21 -0
- package/dist/secret-store.js.map +1 -1
- package/dist/service-installer.d.ts +75 -0
- package/dist/service-installer.js +206 -0
- package/dist/service-installer.js.map +1 -1
- package/dist/service-owner.d.ts +44 -0
- package/dist/service-owner.js +48 -0
- package/dist/service-owner.js.map +1 -0
- package/dist/service-plan.d.ts +39 -2
- package/dist/service-plan.js +188 -6
- package/dist/service-plan.js.map +1 -1
- package/dist/session-oom.d.ts +21 -0
- package/dist/session-oom.js +139 -0
- package/dist/session-oom.js.map +1 -0
- package/dist/settings-api.d.ts +13 -0
- package/dist/settings-api.js +73 -12
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-baseline.js.map +1 -1
- package/dist/settings-change.d.ts +5 -0
- package/dist/settings-change.js +167 -6
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-confirmation.d.ts +9 -1
- package/dist/settings-confirmation.js +18 -8
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-control.js +2 -1
- package/dist/settings-control.js.map +1 -1
- package/dist/settings-effect.js +29 -4
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.d.ts +13 -0
- package/dist/settings-http-confirmation.js +15 -3
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/settings-instance-patch.js +13 -1
- package/dist/settings-instance-patch.js.map +1 -1
- package/dist/settings-request-capability.d.ts +4 -1
- package/dist/settings-request-capability.js +11 -3
- package/dist/settings-request-capability.js.map +1 -1
- package/dist/settings-transaction.js.map +1 -1
- package/dist/setup-auth.d.ts +1 -0
- package/dist/setup-auth.js +2 -1
- package/dist/setup-auth.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +23 -7
- package/dist/setup-form.js.map +1 -1
- package/dist/setup-host.d.ts +6 -1
- package/dist/setup-host.js +15 -4
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +6 -3
- package/dist/setup-wizard.js.map +1 -1
- package/dist/spawn-gate.d.ts +5 -0
- package/dist/spawn-gate.js +28 -8
- package/dist/spawn-gate.js.map +1 -1
- package/dist/start-attempt.d.ts +1 -0
- package/dist/start-attempt.js +18 -0
- package/dist/start-attempt.js.map +1 -0
- package/dist/start-failure-notice.d.ts +4 -0
- package/dist/start-failure-notice.js +20 -0
- package/dist/start-failure-notice.js.map +1 -0
- package/dist/start-failure.d.ts +147 -0
- package/dist/start-failure.js +281 -0
- package/dist/start-failure.js.map +1 -0
- package/dist/startup-memory-deadline.d.ts +23 -0
- package/dist/startup-memory-deadline.js +55 -0
- package/dist/startup-memory-deadline.js.map +1 -0
- package/dist/statusline-watcher.js.map +1 -1
- package/dist/storm-window.js.map +1 -1
- package/dist/systemd-peer-bus.js.map +1 -1
- package/dist/target-queue-worker.js.map +1 -1
- package/dist/task-board-summary.d.ts +101 -0
- package/dist/task-board-summary.js +177 -0
- package/dist/task-board-summary.js.map +1 -0
- package/dist/task-board-tool.d.ts +25 -0
- package/dist/task-board-tool.js +155 -0
- package/dist/task-board-tool.js.map +1 -0
- package/dist/task-board-waker.d.ts +51 -0
- package/dist/task-board-waker.js +141 -0
- package/dist/task-board-waker.js.map +1 -0
- package/dist/tips.js.map +1 -1
- package/dist/tmux-control.js +1 -1
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-exec-helper.d.ts +104 -0
- package/dist/tmux-exec-helper.js +412 -0
- package/dist/tmux-exec-helper.js.map +1 -0
- package/dist/tmux-manager.d.ts +1 -0
- package/dist/tmux-manager.js +29 -13
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tmux-read.js +2 -2
- package/dist/tmux-read.js.map +1 -1
- package/dist/tool-permissions.js.map +1 -1
- package/dist/tool-progress.js.map +1 -1
- package/dist/topic-archiver.js.map +1 -1
- package/dist/topic-commands.d.ts +13 -0
- package/dist/topic-commands.js +56 -33
- package/dist/topic-commands.js.map +1 -1
- package/dist/topic-link.js.map +1 -1
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/transcript-jsonl.js.map +1 -1
- package/dist/transcript-monitor.js +5 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +2 -0
- package/dist/transcript-sources.js +4 -0
- package/dist/transcript-sources.js.map +1 -1
- package/dist/tunnel/cloudflared-install.js +3 -1
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +7 -16
- package/dist/tunnel/cloudflared.js +100 -13
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/lease.js +3 -2
- package/dist/tunnel/lease.js.map +1 -1
- package/dist/tunnel/manager.js.map +1 -1
- package/dist/tunnel/named-diagnostics.d.ts +13 -0
- package/dist/tunnel/named-diagnostics.js +52 -0
- package/dist/tunnel/named-diagnostics.js.map +1 -0
- package/dist/tunnel/public-fetch.js.map +1 -1
- package/dist/tunnel/purpose-lane.js.map +1 -1
- package/dist/tunnel/types.d.ts +4 -1
- package/dist/tunnel/types.js.map +1 -1
- package/dist/types.d.ts +55 -2
- package/dist/ui/app-effects.js +23 -5
- package/dist/ui/app.html +2 -0
- package/dist/ui/backend-signin.js +297 -0
- package/dist/ui/chat-effects.js +10 -3
- package/dist/ui/chat-msg-actions.js +176 -0
- package/dist/ui/chat-render.js +62 -18
- package/dist/ui/chat-store.js +218 -48
- package/dist/ui/chat-strings.js +40 -8
- package/dist/ui/chat-thread.js +119 -10
- package/dist/ui/effect-trial-offer.js +2 -31
- package/dist/ui/effects-waker.js +2 -0
- package/dist/ui/fleet-canvas-layout.js +13 -4
- package/dist/ui/fleet-canvas.js +69 -28
- package/dist/ui/fleet-office-editor.js +149 -0
- package/dist/ui/fleet-office-model.js +358 -0
- package/dist/ui/fleet-office-pack.js +199 -0
- package/dist/ui/fleet-office-skin.js +474 -0
- package/dist/ui/fleet-office-store.js +49 -0
- package/dist/ui/fleet-office-zones.js +177 -0
- package/dist/ui/fleet-office.js +434 -0
- package/dist/ui/fleet-org.js +18 -5
- package/dist/ui/fleet-route-scene.js +272 -0
- package/dist/ui/fleet-route-worker.js +21 -0
- package/dist/ui/fleet-route.js +88 -0
- package/dist/ui/fleet-task-card.js +436 -0
- package/dist/ui/fleet-task-common.js +482 -0
- package/dist/ui/fleet-task-work.js +107 -0
- package/dist/ui/fleet-tasks.js +569 -0
- package/dist/ui/fleet-traffic.js +81 -18
- package/dist/ui/fleet-usage.js +36 -2
- package/dist/ui/guest-refused.html +41 -0
- package/dist/ui/icons/icon-192.png +0 -0
- package/dist/ui/icons/icon-512.png +0 -0
- package/dist/ui/image-lightbox.js +45 -1
- package/dist/ui/image-zoom.js +242 -0
- package/dist/ui/panel-chat.js +200 -56
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +32 -71
- package/dist/ui/panel-needs.js +23 -6
- package/dist/ui/panel-settings.js +158 -32
- package/dist/ui/preview-panel.js +2 -1
- package/dist/ui/search-dialog.js +350 -0
- package/dist/ui/settings-appearance.js +170 -10
- package/dist/ui/settings-apply.js +3 -3
- package/dist/ui/settings-confirm.js +7 -2
- package/dist/ui/settings-dialogs.js +46 -21
- package/dist/ui/settings-discord.js +6 -2
- package/dist/ui/settings-guests.js +2 -1
- package/dist/ui/settings-learn.js +1 -0
- package/dist/ui/settings-model.js +51 -18
- package/dist/ui/settings-mods.js +8 -3
- package/dist/ui/settings-owner-effects.js +6 -4
- package/dist/ui/settings-strings.js +180 -6
- package/dist/ui/settings-token.js +3 -1
- package/dist/ui/settings-web-authority.js +13 -5
- package/dist/ui/settings-wizard.js +13 -7
- package/dist/ui/shared/agend-auth.js +2 -0
- package/dist/ui/shared/app-i18n.js +12 -5
- package/dist/ui/shared/app-needs.js +2 -2
- package/dist/ui/shared/app-prefs.js +15 -0
- package/dist/ui/shared/app-route.js +18 -0
- package/dist/ui/shared/app-shell.js +172 -28
- package/dist/ui/shared/app-store.js +3 -2
- package/dist/ui/shared/app-stream.js +5 -1
- package/dist/ui/shared/app-themes.js +116 -1
- package/dist/ui/shared/app.css +662 -48
- package/dist/ui/shared/app.js +9 -1
- package/dist/ui/shared/card-strings.js +4 -0
- package/dist/ui/shared/effect-app-runtime.js +7 -5
- package/dist/ui/shared/effect-chat-runtime.js +2 -7
- package/dist/ui/shared/effect-cursor.js +3 -2
- package/dist/ui/shared/effect-display-metadata.js +39 -0
- package/dist/ui/shared/effect-owner-source.js +8 -1
- package/dist/ui/shared/effect-registry.js +14 -4
- package/dist/ui/shared/effect-runtime-loader.js +3 -1
- package/dist/ui/shared/effect-stock-trust.js +5 -5
- package/dist/ui/shared/feature-flags.js +127 -0
- package/dist/ui/shared/guest-refused.js +35 -0
- package/dist/ui/shared/header-tools.js +2 -2
- package/dist/ui/shared/identity-card.js +26 -8
- package/dist/ui/shared/identity-color.js +17 -10
- package/dist/ui/shared/instance-nav.js +183 -28
- package/dist/ui/shared/owner-css.js +30 -1
- package/dist/ui/shared/panel-view.js +14 -29
- package/dist/ui/shared/text-undo.js +210 -0
- package/dist/ui/shared/tokens.css +4 -0
- package/dist/ui/shared/ui-dialog.js +5 -4
- package/dist/ui/shared/ui-icons.js +4 -0
- package/dist/ui/shared/ui-menu.js +20 -7
- package/dist/ui/shared/ui-toast.js +16 -3
- package/dist/ui/shared/usage-dialog.js +206 -16
- package/dist/ui/shared/view-strings.js +68 -4
- package/dist/ui/shared/web-authorize.js +50 -1
- package/dist/ui/signin.html +2 -0
- package/dist/ui/web-terminal/terminal.html +10 -10
- package/dist/ui/web-terminal/terminal.js +58 -12
- package/dist/update-check.js.map +1 -1
- package/dist/update-dispatch.js +1 -1
- package/dist/update-dispatch.js.map +1 -1
- package/dist/update-install.js.map +1 -1
- package/dist/update-launch.d.ts +10 -1
- package/dist/update-launch.js +39 -14
- package/dist/update-launch.js.map +1 -1
- package/dist/update-marker.js.map +1 -1
- package/dist/update-preflight.js +1 -1
- package/dist/update-preflight.js.map +1 -1
- package/dist/update-progress.d.ts +3 -1
- package/dist/update-progress.js +3 -1
- package/dist/update-progress.js.map +1 -1
- package/dist/usage/format-rich.js +6 -3
- package/dist/usage/format-rich.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +2 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/i18n.d.ts +13 -1
- package/dist/usage/i18n.js +29 -0
- package/dist/usage/i18n.js.map +1 -1
- package/dist/usage/providers.d.ts +89 -2
- package/dist/usage/providers.js +247 -41
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/statusline-usage.js.map +1 -1
- package/dist/usage/usage-api.d.ts +5 -1
- package/dist/usage/usage-api.js +185 -58
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/usage-report.js.map +1 -1
- package/dist/usage-rollup.js +1 -1
- package/dist/usage-rollup.js.map +1 -1
- package/dist/user-systemd.d.ts +24 -0
- package/dist/user-systemd.js +75 -0
- package/dist/user-systemd.js.map +1 -0
- package/dist/view-api.d.ts +7 -0
- package/dist/view-api.js +16 -4
- package/dist/view-api.js.map +1 -1
- package/dist/wake-coordinator.js.map +1 -1
- package/dist/web-ambient.js.map +1 -1
- package/dist/web-api.d.ts +57 -10
- package/dist/web-api.js +1103 -70
- package/dist/web-api.js.map +1 -1
- package/dist/web-app-icon-worker.d.ts +1 -0
- package/dist/web-app-icon-worker.js +16 -0
- package/dist/web-app-icon-worker.js.map +1 -0
- package/dist/web-app-icon.d.ts +109 -0
- package/dist/web-app-icon.js +257 -0
- package/dist/web-app-icon.js.map +1 -0
- package/dist/web-appearance-policy.js.map +1 -1
- package/dist/web-appearance-tools.d.ts +1 -0
- package/dist/web-appearance-tools.js +7 -3
- package/dist/web-appearance-tools.js.map +1 -1
- package/dist/web-appearance-view.js.map +1 -1
- package/dist/web-appearance.d.ts +4 -10
- package/dist/web-appearance.js +7 -4
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-auth.d.ts +8 -4
- package/dist/web-auth.js +33 -21
- package/dist/web-auth.js.map +1 -1
- package/dist/web-backend-login.d.ts +130 -0
- package/dist/web-backend-login.js +225 -0
- package/dist/web-backend-login.js.map +1 -0
- package/dist/web-card.js.map +1 -1
- package/dist/web-channel-echo.js.map +1 -1
- package/dist/web-chat-history.d.ts +118 -8
- package/dist/web-chat-history.js +220 -25
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +5 -1
- package/dist/web-commands.js +1 -1
- package/dist/web-commands.js.map +1 -1
- package/dist/web-cookie-names.d.ts +5 -0
- package/dist/web-cookie-names.js +12 -0
- package/dist/web-cookie-names.js.map +1 -0
- package/dist/web-effect-bundle.d.ts +2 -2
- package/dist/web-effect-library.js.map +1 -1
- package/dist/web-effect-playback.js.map +1 -1
- package/dist/web-effect-profiles.d.ts +16 -16
- package/dist/web-effect-profiles.js +3 -2
- package/dist/web-effect-profiles.js.map +1 -1
- package/dist/web-effect-service.js.map +1 -1
- package/dist/web-effect-tools.js.map +1 -1
- package/dist/web-effect-trials.js.map +1 -1
- package/dist/web-effect-upload.js.map +1 -1
- package/dist/web-effect.d.ts +6 -6
- package/dist/web-effect.js.map +1 -1
- package/dist/web-experimental-http.d.ts +2 -0
- package/dist/web-experimental-http.js +12 -0
- package/dist/web-experimental-http.js.map +1 -0
- package/dist/web-experimental.d.ts +11 -0
- package/dist/web-experimental.js +49 -0
- package/dist/web-experimental.js.map +1 -0
- package/dist/web-guest-access.d.ts +4 -2
- package/dist/web-guest-access.js +22 -10
- package/dist/web-guest-access.js.map +1 -1
- package/dist/web-guest-rooms.js.map +1 -1
- package/dist/web-host-guard.d.ts +11 -0
- package/dist/web-host-guard.js +11 -0
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-icons.d.ts +33 -1
- package/dist/web-icons.js +83 -11
- package/dist/web-icons.js.map +1 -1
- package/dist/web-identity-color.js.map +1 -1
- package/dist/web-image-meta.d.ts +13 -0
- package/dist/web-image-meta.js +62 -0
- package/dist/web-image-meta.js.map +1 -1
- package/dist/web-images.js.map +1 -1
- package/dist/web-libavoid.d.ts +14 -0
- package/dist/web-libavoid.js +46 -0
- package/dist/web-libavoid.js.map +1 -0
- package/dist/web-login.js.map +1 -1
- package/dist/web-mentions.js.map +1 -1
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-mod-store.d.ts +2 -0
- package/dist/web-mod-store.js +10 -0
- package/dist/web-mod-store.js.map +1 -1
- package/dist/web-office-store.d.ts +104 -0
- package/dist/web-office-store.js +236 -0
- package/dist/web-office-store.js.map +1 -0
- package/dist/web-owner-css-store.d.ts +44 -1
- package/dist/web-owner-css-store.js +70 -9
- package/dist/web-owner-css-store.js.map +1 -1
- package/dist/web-owner-css.js.map +1 -1
- package/dist/web-png.d.ts +33 -0
- package/dist/web-png.js +329 -0
- package/dist/web-png.js.map +1 -0
- package/dist/web-prefs.d.ts +6 -0
- package/dist/web-prefs.js +29 -3
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +2 -0
- package/dist/web-preview.js +2 -0
- package/dist/web-preview.js.map +1 -1
- package/dist/web-profile.js.map +1 -1
- package/dist/web-reactions.d.ts +35 -0
- package/dist/web-reactions.js +47 -0
- package/dist/web-reactions.js.map +1 -0
- package/dist/web-request-context.d.ts +5 -0
- package/dist/web-request-context.js.map +1 -1
- package/dist/web-returning-device.d.ts +4 -2
- package/dist/web-returning-device.js +13 -6
- package/dist/web-returning-device.js.map +1 -1
- package/dist/web-schedules.js.map +1 -1
- package/dist/web-send-dedupe.d.ts +36 -0
- package/dist/web-send-dedupe.js +69 -0
- package/dist/web-send-dedupe.js.map +1 -0
- package/dist/web-session-authorization.js.map +1 -1
- package/dist/web-session.js +1 -1
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +3 -1
- package/dist/web-settings-patch.js +11 -5
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-settings-tools.d.ts +6 -0
- package/dist/web-settings-tools.js +52 -17
- package/dist/web-settings-tools.js.map +1 -1
- package/dist/web-terminal-http.js +15 -2
- package/dist/web-terminal-http.js.map +1 -1
- package/dist/web-terminal.js +1 -1
- package/dist/web-terminal.js.map +1 -1
- package/dist/web-theme-store.d.ts +2 -2
- package/dist/web-theme-store.js +6 -3
- package/dist/web-theme-store.js.map +1 -1
- package/dist/web-theme.d.ts +29 -0
- package/dist/web-theme.js +89 -1
- package/dist/web-theme.js.map +1 -1
- package/dist/web-upload.d.ts +18 -1
- package/dist/web-upload.js +45 -7
- package/dist/web-upload.js.map +1 -1
- package/dist/web-widget.js.map +1 -1
- package/dist/webhook-emitter.js.map +1 -1
- package/dist/windows-shortcuts.d.ts +78 -0
- package/dist/windows-shortcuts.js +203 -0
- package/dist/windows-shortcuts.js.map +1 -0
- package/dist/workflow-templates/default.md +3 -0
- package/dist/ws-server.js.map +1 -1
- package/dist/yaml-shared-nodes.d.ts +25 -0
- package/dist/yaml-shared-nodes.js +185 -0
- package/dist/yaml-shared-nodes.js.map +1 -0
- package/launcher/agend-install.cjs +828 -0
- package/launcher/install-admission.cjs +4 -39
- package/launcher/postinstall.cjs +14 -12
- package/launcher/private-home.cjs +43 -0
- package/package.json +16 -13
- package/scripts/preinstall-guard.cjs +2 -5
- package/templates/windows-shortcuts.ps1 +181 -0
- package/launcher/old-updater-note.cjs +0 -48
|
@@ -0,0 +1,2179 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
export const CARD_STATUSES = ["open", "claimed", "blocked", "review", "done", "cancelled"];
|
|
3
|
+
/** Statuses still on the board (not finished). */
|
|
4
|
+
export const LIVE_CARD_STATUSES = ["open", "claimed", "blocked", "review"];
|
|
5
|
+
export const CARD_PRIORITIES = ["low", "normal", "high", "urgent"];
|
|
6
|
+
export const CARD_SIZES = ["S", "M", "L"];
|
|
7
|
+
export const SYSTEM_ACTOR = { kind: "system", name: "system" };
|
|
8
|
+
/** Field limits in characters (code points). Small on purpose: an agent reads the whole card. */
|
|
9
|
+
export const CARD_LIMITS = {
|
|
10
|
+
title: 100,
|
|
11
|
+
goal: 300,
|
|
12
|
+
scope: 600,
|
|
13
|
+
acceptanceItems: 10,
|
|
14
|
+
acceptanceText: 150,
|
|
15
|
+
context: 1000,
|
|
16
|
+
state: 500,
|
|
17
|
+
stateLines: 5,
|
|
18
|
+
blockedReason: 400,
|
|
19
|
+
result: 1000,
|
|
20
|
+
reason: 400,
|
|
21
|
+
log: 300,
|
|
22
|
+
comment: 4000,
|
|
23
|
+
projectName: 60,
|
|
24
|
+
projectDescription: 300,
|
|
25
|
+
assignee: 128,
|
|
26
|
+
};
|
|
27
|
+
/** How far back finished sub tasks and cancelled main tasks stay on the board. */
|
|
28
|
+
const BOARD_DAYS = 14;
|
|
29
|
+
const BATCH_MAX = 30;
|
|
30
|
+
/**
|
|
31
|
+
* A refusal. `code` is its kind (and the HTTP status the dashboard gets); `key` and `args`, when set, name the same
|
|
32
|
+
* sentence for the dashboard to say in its own language (tasks.err.<key> with {0}, {1}… = args). Refusals only an
|
|
33
|
+
* instance can meet keep English only: they reach the instance, which reads the message.
|
|
34
|
+
*/
|
|
35
|
+
export class TaskBoardError extends Error {
|
|
36
|
+
code;
|
|
37
|
+
key;
|
|
38
|
+
args;
|
|
39
|
+
constructor(code, message, key, args = []) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.key = key;
|
|
43
|
+
this.args = args;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const STATUS_ORDER = `CASE status WHEN 'claimed' THEN 0 WHEN 'blocked' THEN 1 WHEN 'review' THEN 2 WHEN 'open' THEN 3 WHEN 'done' THEN 4 ELSE 5 END`;
|
|
47
|
+
const PRIORITY_ORDER = `CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END`;
|
|
48
|
+
// Keep the second whitespace run inside the hyphen branch: without a hyphen, adjacent runs backtrack quadratically.
|
|
49
|
+
const CODE_RE = /^\s*([MS])\s*(?:-\s*)?(\d+)\s*$/i;
|
|
50
|
+
const length = (s) => [...s].length;
|
|
51
|
+
const isFinished = (c) => c.status === "done" || c.status === "cancelled";
|
|
52
|
+
const isPerson = (a) => a.kind === "person";
|
|
53
|
+
/** The field a too-long refusal names, for the dashboard's word for it. */
|
|
54
|
+
const FIELD_KEY = {
|
|
55
|
+
"the title": "title", "the goal": "goal", "the scope": "scope", "the context": "context", "state": "state", "the result": "result",
|
|
56
|
+
"the blocked reason": "blockedReason", "the reason": "reason", "the log line": "log", "the comment": "comment",
|
|
57
|
+
"an acceptance item": "acceptance", "the description": "projectDescription", "the project's name": "projectName",
|
|
58
|
+
};
|
|
59
|
+
function checkText(field, value, max) {
|
|
60
|
+
const v = value.trim();
|
|
61
|
+
if (length(v) > max)
|
|
62
|
+
throw new TaskBoardError("too_long", `${field} is too long: ${length(v)} characters, at most ${max}`, "too_long", [FIELD_KEY[field] ?? field, length(v), max]);
|
|
63
|
+
return v;
|
|
64
|
+
}
|
|
65
|
+
function checkState(value) {
|
|
66
|
+
const v = checkText("state", value, CARD_LIMITS.state);
|
|
67
|
+
const lines = v.split("\n").filter((l) => l.trim() !== "").length;
|
|
68
|
+
if (lines > CARD_LIMITS.stateLines) {
|
|
69
|
+
throw new TaskBoardError("too_long", `state is at most ${CARD_LIMITS.stateLines} lines (it has ${lines}): done / next / watch out; details go in the log`, "state_lines", [CARD_LIMITS.stateLines, lines]);
|
|
70
|
+
}
|
|
71
|
+
return v;
|
|
72
|
+
}
|
|
73
|
+
function checkAcceptance(items, existing = []) {
|
|
74
|
+
return items.map((raw) => {
|
|
75
|
+
const text = checkText("an acceptance item", raw, CARD_LIMITS.acceptanceText);
|
|
76
|
+
if (!text)
|
|
77
|
+
throw new TaskBoardError("invalid", "an acceptance item cannot be empty", "acceptance_empty");
|
|
78
|
+
return { text, done: existing.find((e) => e.text === text)?.done ?? false };
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** A dispatcher as written: an instance by name, the person writing it, or nobody (null, "", "none"). */
|
|
82
|
+
function toDispatcher(input, actor) {
|
|
83
|
+
if (input === null)
|
|
84
|
+
return null;
|
|
85
|
+
if (input.kind === "person") {
|
|
86
|
+
if (!isPerson(actor))
|
|
87
|
+
throw new TaskBoardError("forbidden", "only a person can name themselves the dispatcher");
|
|
88
|
+
return { kind: "person", name: actor.name };
|
|
89
|
+
}
|
|
90
|
+
const name = normalizeAssignee(input.name);
|
|
91
|
+
return name ? { kind: "instance", name } : null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A card an instance opened for itself and nobody handed out (its own review, its own notes): its own business, so it
|
|
95
|
+
* neither counts toward nor is held to the cards-at-once limit (#2303's busy-fleet check: a reviewer opens one per
|
|
96
|
+
* review and runs several). A sub task it splits off a card handed to it takes that card's dispatcher, so it counts.
|
|
97
|
+
*/
|
|
98
|
+
const openedForItself = (c) => c.assignee !== null && c.createdBy === c.assignee && c.dispatcher === null;
|
|
99
|
+
const isDispatcher = (card, actor) => card.dispatcher?.kind === "instance" && !isPerson(actor) && card.dispatcher.name === actor.name;
|
|
100
|
+
/**
|
|
101
|
+
* #2434: who confirms a card in review. The person confirms the cards they handed out (#2426's rule for a blocked card,
|
|
102
|
+
* for review too); an agent's card is the agents' own business: its instance dispatcher confirms it, and one nobody
|
|
103
|
+
* handed out its assignee (or the instance that opened it). A person may still confirm any card.
|
|
104
|
+
*/
|
|
105
|
+
export const personConfirms = (card) => card.dispatcher?.kind === "person";
|
|
106
|
+
function confirms(card, actor) {
|
|
107
|
+
if (isPerson(actor))
|
|
108
|
+
return true;
|
|
109
|
+
if (personConfirms(card))
|
|
110
|
+
return false;
|
|
111
|
+
if (card.dispatcher?.kind === "instance")
|
|
112
|
+
return card.dispatcher.name === actor.name;
|
|
113
|
+
return card.assignee === actor.name || (card.assignee === null && card.createdBy === actor.name);
|
|
114
|
+
}
|
|
115
|
+
/** Who that is, in words (an error, the card's text). */
|
|
116
|
+
export function confirmerOf(card) {
|
|
117
|
+
if (card.dispatcher?.kind === "person")
|
|
118
|
+
return "the person";
|
|
119
|
+
if (card.dispatcher?.kind === "instance")
|
|
120
|
+
return card.dispatcher.name;
|
|
121
|
+
return card.assignee ?? "its assignee";
|
|
122
|
+
}
|
|
123
|
+
function normalizeAssignee(raw) {
|
|
124
|
+
const v = raw?.trim() ?? "";
|
|
125
|
+
if (v === "" || v.toLowerCase() === "none")
|
|
126
|
+
return null;
|
|
127
|
+
if (length(v) > CARD_LIMITS.assignee)
|
|
128
|
+
throw new TaskBoardError("invalid", "the assignee's name is too long");
|
|
129
|
+
return v;
|
|
130
|
+
}
|
|
131
|
+
/** "m3", "M - 3" → "M-3"; null when it is not a card number. */
|
|
132
|
+
export function cardCode(raw) {
|
|
133
|
+
const m = CODE_RE.exec(raw);
|
|
134
|
+
return m ? `${m[1].toUpperCase()}-${Number(m[2])}` : null;
|
|
135
|
+
}
|
|
136
|
+
const orphaned = (card, gone) => !!gone && card.dispatcher?.kind === "instance" && gone(card.dispatcher.name);
|
|
137
|
+
/**
|
|
138
|
+
* #2425: whether a card waits for the person — in review needing their confirmation, or blocked on a question the person
|
|
139
|
+
* owns; both only when they handed it out (#2434: an agent's card in review is its dispatcher's to confirm), or when the
|
|
140
|
+
* instance that handed it out was deleted since (`gone`: nobody else is left to answer it). The one rule for
|
|
141
|
+
* waitingForPerson's query, Needs you and a button's route.
|
|
142
|
+
*/
|
|
143
|
+
export function waitsForPerson(card, gone) {
|
|
144
|
+
return (personConfirms(card) || orphaned(card, gone)) && ((card.status === "review" && card.needsReview) || card.status === "blocked");
|
|
145
|
+
}
|
|
146
|
+
export function requiresReview(card) {
|
|
147
|
+
return card.needsReview;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* What a finished card still lacks for the person's confirmation: unchecked acceptance items, no result. A main task
|
|
151
|
+
* goes to review by itself when its last sub task is done (often a reviewer's), before its owner wrapped it up.
|
|
152
|
+
*/
|
|
153
|
+
export function wrapUpGap(card) {
|
|
154
|
+
const unchecked = card.acceptance.filter((a) => !a.done).length;
|
|
155
|
+
const noResult = !card.result.trim();
|
|
156
|
+
return unchecked || noResult ? { unchecked, noResult } : null;
|
|
157
|
+
}
|
|
158
|
+
function parseJsonArray(raw, fallback) {
|
|
159
|
+
if (typeof raw !== "string" || raw === "")
|
|
160
|
+
return fallback;
|
|
161
|
+
try {
|
|
162
|
+
const v = JSON.parse(raw);
|
|
163
|
+
return Array.isArray(v) ? v : fallback;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return fallback;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function toCard(r) {
|
|
170
|
+
return {
|
|
171
|
+
uuid: r.id,
|
|
172
|
+
code: r.code ?? "",
|
|
173
|
+
kind: r.kind === "sub" ? "sub" : "main",
|
|
174
|
+
projectId: r.project_id === null || r.project_id === undefined ? null : Number(r.project_id),
|
|
175
|
+
parent: r.parent_id ?? null,
|
|
176
|
+
title: r.title,
|
|
177
|
+
status: r.status,
|
|
178
|
+
assignee: r.assignee ?? null,
|
|
179
|
+
dispatcher: r.dispatcher ? { kind: r.dispatcher_kind === "person" ? "person" : "instance", name: r.dispatcher } : null,
|
|
180
|
+
priority: r.priority ?? "normal",
|
|
181
|
+
size: r.size ?? null,
|
|
182
|
+
needsReview: Number(r.needs_review) === 1,
|
|
183
|
+
onBoard: Number(r.on_board) === 1,
|
|
184
|
+
deps: parseJsonArray(r.depends_on, []).filter((d) => typeof d === "string"),
|
|
185
|
+
goal: r.description ?? "",
|
|
186
|
+
scope: r.scope ?? "",
|
|
187
|
+
acceptance: parseJsonArray(r.acceptance, []),
|
|
188
|
+
context: r.context ?? "",
|
|
189
|
+
state: r.state ?? "",
|
|
190
|
+
blockedReason: r.blocked_reason ?? "",
|
|
191
|
+
result: r.result ?? "",
|
|
192
|
+
sort: Number(r.sort ?? 0),
|
|
193
|
+
createdBy: r.created_by,
|
|
194
|
+
version: Number(r.version ?? 1),
|
|
195
|
+
createdAt: r.created_at,
|
|
196
|
+
updatedAt: r.updated_at,
|
|
197
|
+
startedAt: r.started_at ?? null,
|
|
198
|
+
submittedAt: r.submitted_at ?? null,
|
|
199
|
+
doneAt: r.done_at ?? null,
|
|
200
|
+
seenEntryId: Number(r.seen_entry_id ?? 0),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* #2352: a lead is a name, or none ("" and null alike). Whether it is one of the fleet's instances is the fleet's to
|
|
205
|
+
* check (the board keeps names, as for an assignee); a lead no longer in fleet.yaml reads as none.
|
|
206
|
+
*/
|
|
207
|
+
function checkLeadInstance(raw) {
|
|
208
|
+
const name = (raw ?? "").trim();
|
|
209
|
+
if (!name)
|
|
210
|
+
return null;
|
|
211
|
+
// eslint-disable-next-line no-control-regex
|
|
212
|
+
if (name.length > 256 || /[\s\u0000-\u001f\u007f]/.test(name))
|
|
213
|
+
throw new TaskBoardError("invalid", "a lead is an instance's name", "lead_not_instance");
|
|
214
|
+
return name;
|
|
215
|
+
}
|
|
216
|
+
function toProject(r) {
|
|
217
|
+
return {
|
|
218
|
+
id: Number(r.id),
|
|
219
|
+
name: r.name,
|
|
220
|
+
description: r.description,
|
|
221
|
+
archived: Number(r.archived) === 1,
|
|
222
|
+
leadInstance: typeof r.lead_instance === "string" && r.lead_instance ? r.lead_instance : null,
|
|
223
|
+
sort: Number(r.sort),
|
|
224
|
+
createdAt: r.created_at,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
export class TaskBoard {
|
|
228
|
+
db;
|
|
229
|
+
now;
|
|
230
|
+
listeners = [];
|
|
231
|
+
cardLimit = () => 1;
|
|
232
|
+
pending = [];
|
|
233
|
+
projectsChanged = false;
|
|
234
|
+
notes = [];
|
|
235
|
+
/** The notes of the last write, until a caller of the `task` tool's old actions takes them (takeNotes). */
|
|
236
|
+
lastNotes = [];
|
|
237
|
+
codes = new Map();
|
|
238
|
+
constructor(db, now = () => new Date()) {
|
|
239
|
+
this.db = db;
|
|
240
|
+
this.now = now;
|
|
241
|
+
}
|
|
242
|
+
/** Called with what each committed write changed. Returns an unsubscribe function. */
|
|
243
|
+
onChange(listener) {
|
|
244
|
+
this.listeners.push(listener);
|
|
245
|
+
return () => { this.listeners = this.listeners.filter((l) => l !== listener); };
|
|
246
|
+
}
|
|
247
|
+
// ── Schema ──────────────────────────────────────────────
|
|
248
|
+
/**
|
|
249
|
+
* Adds the board's columns and tables if missing, then backfills rows that have no card number yet (every task from
|
|
250
|
+
* before the board, and any a release without the board created since), drops comments and history whose task is
|
|
251
|
+
* gone, and lets main tasks catch up with their sub tasks. Safe to run on every open.
|
|
252
|
+
*/
|
|
253
|
+
migrate() {
|
|
254
|
+
const cols = new Set(this.db.prepare("PRAGMA table_info(tasks)").all().map((c) => c.name));
|
|
255
|
+
const add = (name, decl) => {
|
|
256
|
+
if (cols.has(name))
|
|
257
|
+
return;
|
|
258
|
+
// The CLI and the fleet can open the file at the same moment: the other one adding it first is fine.
|
|
259
|
+
try {
|
|
260
|
+
this.db.exec(`ALTER TABLE tasks ADD COLUMN ${name} ${decl}`);
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
if (!/duplicate column/i.test(String(err)))
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
add("code", "TEXT");
|
|
268
|
+
add("kind", "TEXT");
|
|
269
|
+
add("project_id", "INTEGER");
|
|
270
|
+
add("parent_id", "TEXT");
|
|
271
|
+
add("scope", "TEXT NOT NULL DEFAULT ''");
|
|
272
|
+
add("acceptance", "TEXT NOT NULL DEFAULT '[]'");
|
|
273
|
+
add("context", "TEXT NOT NULL DEFAULT ''");
|
|
274
|
+
add("state", "TEXT NOT NULL DEFAULT ''");
|
|
275
|
+
add("blocked_reason", "TEXT NOT NULL DEFAULT ''");
|
|
276
|
+
add("size", "TEXT");
|
|
277
|
+
add("needs_review", "INTEGER NOT NULL DEFAULT 0");
|
|
278
|
+
add("version", "INTEGER NOT NULL DEFAULT 1");
|
|
279
|
+
add("sort", "REAL");
|
|
280
|
+
add("started_at", "TEXT");
|
|
281
|
+
add("submitted_at", "TEXT");
|
|
282
|
+
add("done_at", "TEXT");
|
|
283
|
+
add("seen_entry_id", "INTEGER NOT NULL DEFAULT 0");
|
|
284
|
+
add("dispatcher", "TEXT");
|
|
285
|
+
add("dispatcher_kind", "TEXT");
|
|
286
|
+
add("on_board", "INTEGER NOT NULL DEFAULT 0"); // #2434; a row before it: its needs_review says so
|
|
287
|
+
this.db.exec(`
|
|
288
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_code ON tasks(code);
|
|
289
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
|
|
290
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
|
|
291
|
+
CREATE TABLE IF NOT EXISTS task_projects (
|
|
292
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
293
|
+
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
294
|
+
description TEXT NOT NULL DEFAULT '',
|
|
295
|
+
archived INTEGER NOT NULL DEFAULT 0,
|
|
296
|
+
sort REAL NOT NULL,
|
|
297
|
+
created_at TEXT NOT NULL
|
|
298
|
+
);
|
|
299
|
+
CREATE TABLE IF NOT EXISTS task_entries (
|
|
300
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
301
|
+
task_id TEXT NOT NULL,
|
|
302
|
+
kind TEXT NOT NULL,
|
|
303
|
+
author TEXT NOT NULL,
|
|
304
|
+
author_kind TEXT NOT NULL,
|
|
305
|
+
text TEXT NOT NULL,
|
|
306
|
+
at TEXT NOT NULL
|
|
307
|
+
);
|
|
308
|
+
CREATE INDEX IF NOT EXISTS idx_task_entries_task ON task_entries(task_id, id);
|
|
309
|
+
CREATE TABLE IF NOT EXISTS task_events (
|
|
310
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
311
|
+
at TEXT NOT NULL,
|
|
312
|
+
actor TEXT NOT NULL,
|
|
313
|
+
actor_kind TEXT NOT NULL,
|
|
314
|
+
task_id TEXT NOT NULL,
|
|
315
|
+
type TEXT NOT NULL,
|
|
316
|
+
data TEXT NOT NULL DEFAULT '{}'
|
|
317
|
+
);
|
|
318
|
+
CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_id, id);
|
|
319
|
+
CREATE TABLE IF NOT EXISTS task_counters (prefix TEXT PRIMARY KEY, next INTEGER NOT NULL);
|
|
320
|
+
`);
|
|
321
|
+
// #2352: a project's lead instance, added in place (null: its waiting cards' buttons stay in General).
|
|
322
|
+
const projectCols = new Set(this.db.prepare("PRAGMA table_info(task_projects)").all().map((c) => c.name));
|
|
323
|
+
if (!projectCols.has("lead_instance")) {
|
|
324
|
+
try {
|
|
325
|
+
this.db.exec("ALTER TABLE task_projects ADD COLUMN lead_instance TEXT");
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
if (!/duplicate column/i.test(String(err)))
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// Every open (the CLI opens this file for each command) only reads: the write lock is taken only when a counter
|
|
333
|
+
// is missing or a row needs a number. (CREATE … IF NOT EXISTS of what exists takes no lock; an INSERT does.)
|
|
334
|
+
if (this.db.prepare("SELECT COUNT(*) AS n FROM task_counters WHERE prefix IN ('M', 'S', 'R')").get().n < 3) {
|
|
335
|
+
this.db.exec("INSERT OR IGNORE INTO task_counters (prefix, next) VALUES ('M', 1), ('S', 1), ('R', 0)");
|
|
336
|
+
}
|
|
337
|
+
if (this.db.prepare("SELECT 1 FROM tasks WHERE code IS NULL OR sort IS NULL LIMIT 1").get()) {
|
|
338
|
+
this.tx(() => {
|
|
339
|
+
const rows = this.db.prepare("SELECT id, status, updated_at FROM tasks WHERE code IS NULL ORDER BY created_at, rowid").all();
|
|
340
|
+
const fill = this.db.prepare("UPDATE tasks SET code = ?, kind = 'main', sort = COALESCE(sort, ?), done_at = COALESCE(done_at, ?) WHERE id = ?");
|
|
341
|
+
for (const r of rows)
|
|
342
|
+
fill.run(this.nextCode("M"), this.nextSort(), r.status === "done" ? r.updated_at : null, r.id);
|
|
343
|
+
this.db.exec("UPDATE tasks SET sort = rowid WHERE sort IS NULL");
|
|
344
|
+
if (rows.length) {
|
|
345
|
+
this.projectsChanged = true;
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
this.reconcile();
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Main tasks whose sub tasks moved while nothing derived them (under a release without the board). Read first; the
|
|
353
|
+
* write lock only when one is out of step.
|
|
354
|
+
*/
|
|
355
|
+
reconcile() {
|
|
356
|
+
const rows = this.db.prepare("SELECT DISTINCT p.* FROM tasks p JOIN tasks c ON c.parent_id = p.id WHERE p.kind = 'main' AND p.status NOT IN ('cancelled', 'blocked')").all();
|
|
357
|
+
const behind = rows.map(toCard).filter((main) => this.derivedStatus(main) !== main.status);
|
|
358
|
+
if (!behind.length)
|
|
359
|
+
return;
|
|
360
|
+
this.tx(() => { for (const main of behind) {
|
|
361
|
+
const fresh = this.byUuid(main.uuid);
|
|
362
|
+
if (fresh)
|
|
363
|
+
this.deriveMain(fresh, SYSTEM_ACTOR);
|
|
364
|
+
} });
|
|
365
|
+
}
|
|
366
|
+
/** The board's revision: it moves with every committed change, so a page reads the board again only when it moved. */
|
|
367
|
+
revision() {
|
|
368
|
+
return Number(this.db.prepare("SELECT next FROM task_counters WHERE prefix = 'R'").get()?.next ?? 0);
|
|
369
|
+
}
|
|
370
|
+
// ── Projects ────────────────────────────────────────────
|
|
371
|
+
listProjects(includeArchived = false) {
|
|
372
|
+
const rows = this.db.prepare(`SELECT * FROM task_projects ${includeArchived ? "" : "WHERE archived = 0"} ORDER BY archived, sort, id`).all();
|
|
373
|
+
return rows.map(toProject);
|
|
374
|
+
}
|
|
375
|
+
/** By id, or by name (case-insensitive). */
|
|
376
|
+
getProject(ref) {
|
|
377
|
+
const text = String(ref).trim();
|
|
378
|
+
const row = /^\d+$/.test(text)
|
|
379
|
+
? this.db.prepare("SELECT * FROM task_projects WHERE id = ?").get(Number(text))
|
|
380
|
+
: this.db.prepare("SELECT * FROM task_projects WHERE name = ?").get(text);
|
|
381
|
+
if (!row) {
|
|
382
|
+
const names = this.listProjects().map((p) => p.name);
|
|
383
|
+
throw new TaskBoardError("not_found", `no project "${text}"${names.length ? ` (projects: ${names.join(", ")})` : ""}`, "project_not_found", [text]);
|
|
384
|
+
}
|
|
385
|
+
return toProject(row);
|
|
386
|
+
}
|
|
387
|
+
createProject(actor, input) {
|
|
388
|
+
this.personOnly(actor, "create a project");
|
|
389
|
+
return this.tx(() => {
|
|
390
|
+
const name = this.checkProjectName(input.name, null);
|
|
391
|
+
const description = checkText("the description", input.description ?? "", CARD_LIMITS.projectDescription);
|
|
392
|
+
const { s } = this.db.prepare("SELECT COALESCE(MAX(sort), 0) + 1 AS s FROM task_projects").get();
|
|
393
|
+
const lead = checkLeadInstance(input.leadInstance ?? null);
|
|
394
|
+
const row = this.db.prepare("INSERT INTO task_projects (name, description, sort, created_at, lead_instance) VALUES (?, ?, ?, ?, ?) RETURNING *")
|
|
395
|
+
.get(name, description, s, this.nowIso(), lead);
|
|
396
|
+
this.projectsChanged = true;
|
|
397
|
+
return toProject(row);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
updateProject(actor, id, patch) {
|
|
401
|
+
this.personOnly(actor, "change a project");
|
|
402
|
+
return this.tx(() => {
|
|
403
|
+
const project = this.getProject(id);
|
|
404
|
+
if (patch.name !== undefined)
|
|
405
|
+
project.name = this.checkProjectName(patch.name, project.id);
|
|
406
|
+
if (patch.description !== undefined)
|
|
407
|
+
project.description = checkText("the description", patch.description, CARD_LIMITS.projectDescription);
|
|
408
|
+
if (patch.archived !== undefined)
|
|
409
|
+
project.archived = patch.archived;
|
|
410
|
+
if (patch.leadInstance !== undefined)
|
|
411
|
+
project.leadInstance = checkLeadInstance(patch.leadInstance);
|
|
412
|
+
this.db.prepare("UPDATE task_projects SET name = ?, description = ?, archived = ?, lead_instance = ? WHERE id = ?")
|
|
413
|
+
.run(project.name, project.description, project.archived ? 1 : 0, project.leadInstance, project.id);
|
|
414
|
+
this.projectsChanged = true;
|
|
415
|
+
return project;
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
checkProjectName(raw, selfId) {
|
|
419
|
+
const name = checkText("the project's name", raw, CARD_LIMITS.projectName);
|
|
420
|
+
if (!name)
|
|
421
|
+
throw new TaskBoardError("invalid", "a project needs a name", "project_name_empty");
|
|
422
|
+
if (/^\d+$/.test(name))
|
|
423
|
+
throw new TaskBoardError("invalid", "a project's name cannot be only digits", "project_name_digits");
|
|
424
|
+
const clash = this.db.prepare("SELECT id FROM task_projects WHERE name = ?").get(name);
|
|
425
|
+
if (clash && Number(clash.id) !== selfId)
|
|
426
|
+
throw new TaskBoardError("invalid", `there is already a project named "${name}"`, "project_name_taken", [name]);
|
|
427
|
+
return name;
|
|
428
|
+
}
|
|
429
|
+
/** A new main task's project: the one asked for (not archived), or none. */
|
|
430
|
+
projectForMain(ref) {
|
|
431
|
+
if (ref === undefined || ref === null || String(ref).trim() === "")
|
|
432
|
+
return null;
|
|
433
|
+
const project = this.getProject(ref);
|
|
434
|
+
if (project.archived)
|
|
435
|
+
throw new TaskBoardError("invalid", `project "${project.name}" is archived; it takes no new main tasks`, "project_archived", [project.name]);
|
|
436
|
+
return project.id;
|
|
437
|
+
}
|
|
438
|
+
// ── Reading ─────────────────────────────────────────────
|
|
439
|
+
/** A card by number (M-3), uuid, or a unique uuid prefix of at least 8 hex digits. */
|
|
440
|
+
findCard(ref) {
|
|
441
|
+
const text = (ref ?? "").trim();
|
|
442
|
+
if (!text)
|
|
443
|
+
return undefined;
|
|
444
|
+
const code = cardCode(text);
|
|
445
|
+
if (code) {
|
|
446
|
+
const r = this.db.prepare("SELECT * FROM tasks WHERE code = ?").get(code);
|
|
447
|
+
return r ? toCard(r) : undefined;
|
|
448
|
+
}
|
|
449
|
+
const exact = this.db.prepare("SELECT * FROM tasks WHERE id = ?").get(text);
|
|
450
|
+
if (exact)
|
|
451
|
+
return toCard(exact);
|
|
452
|
+
if (!/^[0-9a-f]{8,}$/i.test(text))
|
|
453
|
+
return undefined;
|
|
454
|
+
const rows = this.db.prepare("SELECT * FROM tasks WHERE id LIKE ? || '%' LIMIT 2").all(text.toLowerCase());
|
|
455
|
+
return rows.length === 1 ? toCard(rows[0]) : undefined;
|
|
456
|
+
}
|
|
457
|
+
getCard(ref) {
|
|
458
|
+
const card = this.findCard(ref);
|
|
459
|
+
if (!card)
|
|
460
|
+
throw new TaskBoardError("not_found", `no card ${cardCode(ref) ?? `"${ref}"`}`, "card_not_found", [cardCode(ref) ?? ref]);
|
|
461
|
+
return card;
|
|
462
|
+
}
|
|
463
|
+
byUuid(uuid) {
|
|
464
|
+
const r = this.db.prepare("SELECT * FROM tasks WHERE id = ?").get(uuid);
|
|
465
|
+
return r ? toCard(r) : undefined;
|
|
466
|
+
}
|
|
467
|
+
/** A uuid's card number, for views and messages ("?" for one that is gone). */
|
|
468
|
+
codeOf(uuid) {
|
|
469
|
+
const known = this.codes.get(uuid);
|
|
470
|
+
if (known)
|
|
471
|
+
return known;
|
|
472
|
+
const r = this.db.prepare("SELECT code FROM tasks WHERE id = ?").get(uuid);
|
|
473
|
+
const code = r?.code ?? uuid.slice(0, 8);
|
|
474
|
+
if (r?.code)
|
|
475
|
+
this.codes.set(uuid, r.code);
|
|
476
|
+
return code;
|
|
477
|
+
}
|
|
478
|
+
children(uuid) {
|
|
479
|
+
return this.db.prepare("SELECT * FROM tasks WHERE parent_id = ? ORDER BY sort").all(uuid).map(toCard);
|
|
480
|
+
}
|
|
481
|
+
/** Sub tasks that still count (not cancelled). A main task with none is worked on directly. */
|
|
482
|
+
activeSubs(uuid) {
|
|
483
|
+
return this.children(uuid).filter((k) => k.status !== "cancelled");
|
|
484
|
+
}
|
|
485
|
+
progress(card) {
|
|
486
|
+
const p = { total: 0, open: 0, claimed: 0, blocked: 0, review: 0, done: 0 };
|
|
487
|
+
for (const k of this.activeSubs(card.uuid)) {
|
|
488
|
+
p.total++;
|
|
489
|
+
if (k.status !== "cancelled")
|
|
490
|
+
p[k.status]++;
|
|
491
|
+
}
|
|
492
|
+
return p;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Dependencies that are not done (a cancelled one still holds its dependents, as the fleet's `task` tool always
|
|
496
|
+
* had it). A sub task also waits for its main task's. A dependency that no longer exists does not hold anything.
|
|
497
|
+
*/
|
|
498
|
+
unmetDeps(card) {
|
|
499
|
+
const own = card.deps.map((id) => this.byUuid(id)).filter((c) => !!c && c.status !== "done");
|
|
500
|
+
const main = card.parent ? this.byUuid(card.parent) : undefined;
|
|
501
|
+
if (!main)
|
|
502
|
+
return own;
|
|
503
|
+
return [...own, ...this.unmetDeps(main).filter((c) => !own.some((o) => o.uuid === c.uuid))];
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* What the board shows: live cards, and finished ones from the last `days` — done or cancelled main tasks, done sub
|
|
507
|
+
* tasks — or every done main task with `allDone` (the Done column's "All"). Older finished work stays in the
|
|
508
|
+
* database and in the projects' numbers (projectStats), not in every board read.
|
|
509
|
+
*/
|
|
510
|
+
boardCards(days = BOARD_DAYS, allDone = false) {
|
|
511
|
+
const since = new Date(this.now().getTime() - days * 86_400_000).toISOString();
|
|
512
|
+
const rows = this.db.prepare(`SELECT * FROM tasks WHERE
|
|
513
|
+
(kind = 'main' AND (status NOT IN ('done', 'cancelled') OR (status = 'done' AND (? OR COALESCE(done_at, updated_at) >= ?)) OR (status = 'cancelled' AND updated_at >= ?)))
|
|
514
|
+
OR (kind = 'sub' AND status != 'cancelled' AND (status != 'done' OR COALESCE(done_at, updated_at) >= ?))
|
|
515
|
+
ORDER BY sort`).all(allDone ? 1 : 0, since, since, since);
|
|
516
|
+
return rows.map(toCard);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Each project's numbers (by id; "none" for No project) over all its cards, finished ones included: main tasks,
|
|
520
|
+
* work units (a main task's sub tasks, or one unit for a main task without them; a done main task counts all its
|
|
521
|
+
* units done), cards waiting for the person and blocked, and the last change. One pass over the table.
|
|
522
|
+
*/
|
|
523
|
+
projectStats() {
|
|
524
|
+
const rows = this.db.prepare("SELECT id, kind, status, project_id, parent_id, updated_at FROM tasks").all();
|
|
525
|
+
const subs = new Map();
|
|
526
|
+
for (const r of rows) {
|
|
527
|
+
if (r.kind !== "sub" || !r.parent_id || r.status === "cancelled")
|
|
528
|
+
continue;
|
|
529
|
+
const p = subs.get(r.parent_id) ?? { total: 0, open: 0, claimed: 0, blocked: 0, review: 0, done: 0 };
|
|
530
|
+
p.total++;
|
|
531
|
+
p[r.status]++;
|
|
532
|
+
subs.set(r.parent_id, p);
|
|
533
|
+
}
|
|
534
|
+
const out = {};
|
|
535
|
+
for (const r of rows) {
|
|
536
|
+
const key = r.project_id === null || r.project_id === undefined ? "none" : String(r.project_id);
|
|
537
|
+
const st = out[key] ??= { mains: 0, units: { total: 0, open: 0, claimed: 0, blocked: 0, review: 0, done: 0 }, percent: 0, waiting: 0, blocked: 0, lastActivity: null };
|
|
538
|
+
if (!st.lastActivity || r.updated_at > st.lastActivity)
|
|
539
|
+
st.lastActivity = r.updated_at;
|
|
540
|
+
const status = r.status;
|
|
541
|
+
if (status === "cancelled")
|
|
542
|
+
continue;
|
|
543
|
+
if (status === "review")
|
|
544
|
+
st.waiting++;
|
|
545
|
+
if (status === "blocked")
|
|
546
|
+
st.blocked++;
|
|
547
|
+
if (r.kind === "sub")
|
|
548
|
+
continue;
|
|
549
|
+
st.mains++;
|
|
550
|
+
const p = subs.get(r.id);
|
|
551
|
+
if (status === "done") {
|
|
552
|
+
const n = p?.total || 1;
|
|
553
|
+
st.units.total += n;
|
|
554
|
+
st.units.done += n;
|
|
555
|
+
}
|
|
556
|
+
else if (p?.total)
|
|
557
|
+
for (const k of ["total", "open", "claimed", "blocked", "review", "done"])
|
|
558
|
+
st.units[k] += p[k];
|
|
559
|
+
else {
|
|
560
|
+
st.units.total++;
|
|
561
|
+
st.units[status]++;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
for (const st of Object.values(out))
|
|
565
|
+
st.percent = st.units.total ? Math.round((st.units.done / st.units.total) * 100) : 0;
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
568
|
+
view(card, scope = {}) {
|
|
569
|
+
return this.views([card], scope)[0];
|
|
570
|
+
}
|
|
571
|
+
/** ` AND <column> NOT IN (…)` for a scope's left-out authors, with its values (empty when none). */
|
|
572
|
+
notBy(scope, column) {
|
|
573
|
+
const names = scope.leaveOutAuthors ?? [];
|
|
574
|
+
return names.length ? { sql: ` AND ${column} NOT IN (${names.map(() => "?").join(", ")})`, args: [...names] } : { sql: "", args: [] };
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Many cards as the dashboard draws them, from one read of what they need — every task's number, status,
|
|
578
|
+
* dependencies and parent, sub-task counts, comment counts, the last comment and the unseen ones — instead of a few
|
|
579
|
+
* queries per card: a board read stays a handful of statements however many cards it has (it runs on the fleet's
|
|
580
|
+
* event loop). Same answers as progress(), unmetDeps() and newComments() card by card.
|
|
581
|
+
*/
|
|
582
|
+
views(cards, scope = {}) {
|
|
583
|
+
if (!cards.length)
|
|
584
|
+
return [];
|
|
585
|
+
const all = new Map();
|
|
586
|
+
const progress = new Map();
|
|
587
|
+
for (const r of this.db.prepare("SELECT id, code, status, depends_on, parent_id FROM tasks").all()) {
|
|
588
|
+
const light = { code: r.code ?? r.id.slice(0, 8), status: r.status, deps: parseJsonArray(r.depends_on, []), parent: r.parent_id ?? null };
|
|
589
|
+
all.set(r.id, light);
|
|
590
|
+
if (light.parent && light.status !== "cancelled") {
|
|
591
|
+
const p = progress.get(light.parent) ?? { total: 0, open: 0, claimed: 0, blocked: 0, review: 0, done: 0 };
|
|
592
|
+
p.total++;
|
|
593
|
+
p[light.status]++;
|
|
594
|
+
progress.set(light.parent, p);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const by = this.notBy(scope, "author"), eBy = this.notBy(scope, "e.author");
|
|
598
|
+
const comments = new Map(this.db.prepare(`SELECT e.task_id AS task_id, n.n AS n, e.author AS author, e.author_kind AS author_kind, e.at AS at FROM task_entries e
|
|
599
|
+
JOIN (SELECT task_id, COUNT(*) AS n, MAX(id) AS last FROM task_entries WHERE kind IN ('comment', 'reject', 'approve')${by.sql} GROUP BY task_id) n ON e.id = n.last`).all(...by.args).map((r) => [r.task_id, r]));
|
|
600
|
+
const unseen = new Map(this.db.prepare(`SELECT t.id AS id, COUNT(e.id) AS n FROM tasks t JOIN task_entries e
|
|
601
|
+
ON e.task_id = t.id AND e.id > t.seen_entry_id AND e.kind IN ('comment', 'reject') AND e.author IS NOT t.assignee${eBy.sql}
|
|
602
|
+
GROUP BY t.id`).all(...eBy.args).map((r) => [r.id, r.n]));
|
|
603
|
+
const code = (uuid) => all.get(uuid)?.code ?? uuid.slice(0, 8);
|
|
604
|
+
/** unmetDeps(), on the light rows: own dependencies not done, then the main task's, each once. */
|
|
605
|
+
const unmet = (deps, parent, seen = new Set()) => {
|
|
606
|
+
const out = [];
|
|
607
|
+
for (const d of deps) {
|
|
608
|
+
const dep = all.get(d);
|
|
609
|
+
if (dep && dep.status !== "done" && !seen.has(d)) {
|
|
610
|
+
seen.add(d);
|
|
611
|
+
out.push(dep.code);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const main = parent ? all.get(parent) : undefined;
|
|
615
|
+
return main ? [...out, ...unmet(main.deps, main.parent, seen)] : out;
|
|
616
|
+
};
|
|
617
|
+
return cards.map((card) => {
|
|
618
|
+
const c = comments.get(card.uuid);
|
|
619
|
+
return {
|
|
620
|
+
id: card.code,
|
|
621
|
+
uuid: card.uuid,
|
|
622
|
+
kind: card.kind,
|
|
623
|
+
projectId: card.projectId,
|
|
624
|
+
parentId: card.parent ? code(card.parent) : null,
|
|
625
|
+
title: card.title,
|
|
626
|
+
status: card.status,
|
|
627
|
+
assignee: card.assignee,
|
|
628
|
+
dispatcher: card.dispatcher,
|
|
629
|
+
priority: card.priority,
|
|
630
|
+
size: card.size,
|
|
631
|
+
needsReview: card.needsReview,
|
|
632
|
+
dependsOn: card.deps.filter((d) => all.has(d)).map(code), // a missing one holds nothing, and could not be named
|
|
633
|
+
goal: card.goal,
|
|
634
|
+
scope: card.scope,
|
|
635
|
+
acceptance: card.acceptance,
|
|
636
|
+
context: card.context,
|
|
637
|
+
state: card.state,
|
|
638
|
+
blockedReason: card.blockedReason,
|
|
639
|
+
result: card.result,
|
|
640
|
+
sort: card.sort,
|
|
641
|
+
createdBy: card.createdBy,
|
|
642
|
+
version: card.version,
|
|
643
|
+
createdAt: card.createdAt,
|
|
644
|
+
updatedAt: card.updatedAt,
|
|
645
|
+
startedAt: card.startedAt,
|
|
646
|
+
submittedAt: card.submittedAt,
|
|
647
|
+
doneAt: card.doneAt,
|
|
648
|
+
progress: card.kind === "main" ? progress.get(card.uuid) ?? { total: 0, open: 0, claimed: 0, blocked: 0, review: 0, done: 0 } : null,
|
|
649
|
+
unmetDeps: unmet(card.deps, card.parent),
|
|
650
|
+
commentCount: c?.n ?? 0,
|
|
651
|
+
newCommentCount: unseen.get(card.uuid) ?? 0,
|
|
652
|
+
lastComment: c ? { author: c.author, authorKind: c.author_kind, at: c.at } : null,
|
|
653
|
+
};
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
detail(ref, scope = {}) {
|
|
657
|
+
const card = this.getCard(ref);
|
|
658
|
+
const parent = card.parent ? this.byUuid(card.parent) : undefined;
|
|
659
|
+
return {
|
|
660
|
+
card: this.view(card, scope),
|
|
661
|
+
parent: parent ? { id: parent.code, title: parent.title, status: parent.status } : null,
|
|
662
|
+
children: this.views(this.children(card.uuid), scope),
|
|
663
|
+
comments: this.entries(card.uuid, ["comment", "reject", "approve"], 200),
|
|
664
|
+
logs: this.entries(card.uuid, ["log"], 200),
|
|
665
|
+
history: this.events(card.uuid, 200).filter((e) => e.type !== "reordered"),
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
/** The latest `limit` entries of a card, oldest first. */
|
|
669
|
+
entries(uuid, kinds, limit = 20) {
|
|
670
|
+
const rows = this.db.prepare(`SELECT * FROM task_entries WHERE task_id = ? AND kind IN (${kinds.map(() => "?").join(", ")}) ORDER BY id DESC LIMIT ?`)
|
|
671
|
+
.all(uuid, ...kinds, limit);
|
|
672
|
+
return rows.map((r) => this.toEntry(r)).reverse();
|
|
673
|
+
}
|
|
674
|
+
/** Comments and rejections by others that the assignee has not acted on yet. */
|
|
675
|
+
newComments(card) {
|
|
676
|
+
const rows = this.db.prepare("SELECT * FROM task_entries WHERE task_id = ? AND id > ? AND kind IN ('comment', 'reject') AND author IS NOT ? ORDER BY id")
|
|
677
|
+
.all(card.uuid, card.seenEntryId, card.assignee);
|
|
678
|
+
return rows.map((r) => this.toEntry(r));
|
|
679
|
+
}
|
|
680
|
+
events(uuid, limit = 20) {
|
|
681
|
+
const rows = this.db.prepare("SELECT * FROM task_events WHERE task_id = ? ORDER BY id DESC LIMIT ?").all(uuid, limit);
|
|
682
|
+
return rows.map((r) => ({
|
|
683
|
+
id: Number(r.id),
|
|
684
|
+
at: r.at,
|
|
685
|
+
actor: r.actor,
|
|
686
|
+
actorKind: r.actor_kind,
|
|
687
|
+
cardId: this.codeOf(r.task_id),
|
|
688
|
+
type: r.type,
|
|
689
|
+
data: JSON.parse(r.data),
|
|
690
|
+
})).reverse();
|
|
691
|
+
}
|
|
692
|
+
toEntry(r) {
|
|
693
|
+
return {
|
|
694
|
+
id: Number(r.id),
|
|
695
|
+
cardId: this.codeOf(r.task_id),
|
|
696
|
+
kind: r.kind,
|
|
697
|
+
author: r.author,
|
|
698
|
+
authorKind: r.author_kind,
|
|
699
|
+
text: r.text,
|
|
700
|
+
at: r.at,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Cards matching a phrase in their number, title, text fields, checklist or comments; live cards first, newest
|
|
705
|
+
* first. `match` says where it matched when it is not the title.
|
|
706
|
+
*/
|
|
707
|
+
search(query, limit = 20, scope = {}) {
|
|
708
|
+
const q = query.trim();
|
|
709
|
+
if (!q)
|
|
710
|
+
return [];
|
|
711
|
+
const max = Math.min(Math.max(limit, 1), 50);
|
|
712
|
+
const hits = [];
|
|
713
|
+
if (cardCode(q)) {
|
|
714
|
+
const exact = this.findCard(q);
|
|
715
|
+
if (exact)
|
|
716
|
+
hits.push({ card: exact, match: null });
|
|
717
|
+
}
|
|
718
|
+
const like = `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
719
|
+
// Named, as the rest of this statement: the left-out authors are :a0, :a1, …
|
|
720
|
+
const names = scope.leaveOutAuthors ?? [];
|
|
721
|
+
const notBy = { sql: names.length ? ` AND e.author NOT IN (${names.map((_, i) => `:a${i}`).join(", ")})` : "", params: Object.fromEntries(names.map((n, i) => [`a${i}`, n])) };
|
|
722
|
+
const text = ["title", "description", "scope", "context", "state", "result", "blocked_reason"].map((f) => `t.${f} LIKE :q ESCAPE '\\'`).join(" OR ");
|
|
723
|
+
const rows = this.db.prepare(`SELECT * FROM tasks t
|
|
724
|
+
WHERE ${text}
|
|
725
|
+
OR EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(t.acceptance) THEN t.acceptance ELSE '[]' END) j WHERE json_extract(j.value, '$.text') LIKE :q ESCAPE '\\')
|
|
726
|
+
OR EXISTS (SELECT 1 FROM task_entries e WHERE e.task_id = t.id AND e.text LIKE :q ESCAPE '\\'${notBy.sql})
|
|
727
|
+
ORDER BY CASE WHEN t.status IN ('done', 'cancelled') THEN 1 ELSE 0 END, t.updated_at DESC
|
|
728
|
+
LIMIT :max`).all({ q: like, max, ...notBy.params });
|
|
729
|
+
for (const card of rows.map(toCard)) {
|
|
730
|
+
if (!hits.some((h) => h.card.uuid === card.uuid))
|
|
731
|
+
hits.push({ card, match: this.matchOf(card, q, like, notBy) });
|
|
732
|
+
}
|
|
733
|
+
return hits.slice(0, max);
|
|
734
|
+
}
|
|
735
|
+
matchOf(card, q, like, notBy = { sql: "", params: {} }) {
|
|
736
|
+
const needle = q.toLowerCase();
|
|
737
|
+
if (card.title.toLowerCase().includes(needle))
|
|
738
|
+
return null;
|
|
739
|
+
const snippet = (value) => {
|
|
740
|
+
const i = value.toLowerCase().indexOf(needle);
|
|
741
|
+
const start = Math.max(0, i - 20);
|
|
742
|
+
const end = Math.min(value.length, i + q.length + 30);
|
|
743
|
+
return `${start > 0 ? "…" : ""}${value.slice(start, end).replace(/\s+/g, " ")}${end < value.length ? "…" : ""}`;
|
|
744
|
+
};
|
|
745
|
+
for (const key of ["goal", "scope", "context", "state", "result", "blockedReason"]) {
|
|
746
|
+
if (card[key].toLowerCase().includes(needle))
|
|
747
|
+
return { field: key, text: snippet(card[key]) };
|
|
748
|
+
}
|
|
749
|
+
const item = card.acceptance.find((a) => a.text.toLowerCase().includes(needle));
|
|
750
|
+
if (item)
|
|
751
|
+
return { field: "acceptance", text: snippet(item.text) };
|
|
752
|
+
const entry = this.db.prepare(`SELECT kind, text FROM task_entries e WHERE task_id = :id AND text LIKE :q ESCAPE '\\'${notBy.sql} ORDER BY id DESC LIMIT 1`)
|
|
753
|
+
.get({ id: card.uuid, q: like, ...notBy.params });
|
|
754
|
+
if (entry)
|
|
755
|
+
return { field: entry.kind === "log" ? "log" : "comment", text: snippet(entry.text) };
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Cards by project (an id or name; null: No project), main task (its sub tasks), assignee and status (live ones by
|
|
760
|
+
* default), main tasks first, then by status and priority; at most `limit` (default 50), with how many matched.
|
|
761
|
+
*/
|
|
762
|
+
listCards(filter) {
|
|
763
|
+
const where = [];
|
|
764
|
+
const params = [];
|
|
765
|
+
if (filter.project !== undefined) {
|
|
766
|
+
if (filter.project === null)
|
|
767
|
+
where.push("project_id IS NULL");
|
|
768
|
+
else {
|
|
769
|
+
where.push("project_id = ?");
|
|
770
|
+
params.push(this.getProject(filter.project).id);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (filter.parent) {
|
|
774
|
+
where.push("parent_id = ?");
|
|
775
|
+
params.push(this.getCard(filter.parent).uuid);
|
|
776
|
+
}
|
|
777
|
+
if (filter.assignee) {
|
|
778
|
+
where.push("assignee = ?");
|
|
779
|
+
params.push(filter.assignee);
|
|
780
|
+
}
|
|
781
|
+
const statuses = filter.status?.length ? filter.status : LIVE_CARD_STATUSES;
|
|
782
|
+
where.push(`status IN (${statuses.map(() => "?").join(", ")})`);
|
|
783
|
+
params.push(...statuses);
|
|
784
|
+
const from = `FROM tasks WHERE ${where.join(" AND ")}`;
|
|
785
|
+
const { n } = this.db.prepare(`SELECT COUNT(*) AS n ${from}`).get(...params);
|
|
786
|
+
const limit = Math.min(Math.max(filter.limit ?? 50, 1), 200);
|
|
787
|
+
const rows = this.db.prepare(`SELECT * ${from} ORDER BY CASE kind WHEN 'main' THEN 0 ELSE 1 END, ${STATUS_ORDER}, ${PRIORITY_ORDER}, sort LIMIT ?`).all(...params, limit);
|
|
788
|
+
return { cards: rows.map(toCard), total: n };
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* On the board's terms: opened on it (#2434), in a project, a sub task, one with sub tasks, or one that waits for
|
|
792
|
+
* review (every main task opened on the board before #2434 did).
|
|
793
|
+
*/
|
|
794
|
+
isOnBoard(card) {
|
|
795
|
+
return card.onBoard || card.projectId !== null || card.kind === "sub" || card.needsReview || this.activeSubs(card.uuid).length > 0;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* An instance's work, for `task mine`: what it is doing, what is blocked or waits for the person, what it can start
|
|
799
|
+
* (its own open cards, and the unassigned sub tasks of main tasks it coordinates, dependencies done) and what waits
|
|
800
|
+
* on dependencies; comments it has not seen and work sent back; the main tasks it coordinates and how far along.
|
|
801
|
+
*/
|
|
802
|
+
/** Who holds a live card (open, in progress, blocked, waiting for the person): the instances, and any other name. */
|
|
803
|
+
liveAssignees() {
|
|
804
|
+
return this.db.prepare("SELECT DISTINCT assignee FROM tasks WHERE assignee IS NOT NULL AND status IN ('open', 'claimed', 'blocked', 'review') ORDER BY assignee").all()
|
|
805
|
+
.map((r) => r.assignee);
|
|
806
|
+
}
|
|
807
|
+
myWork(name) {
|
|
808
|
+
const mine = this.db.prepare(`SELECT * FROM tasks WHERE assignee = ? AND status IN ('open', 'claimed', 'blocked', 'review') ORDER BY ${STATUS_ORDER}, ${PRIORITY_ORDER}, sort`)
|
|
809
|
+
.all(name).map(toCard);
|
|
810
|
+
const coordinating = mine.filter((c) => c.kind === "main" && this.activeSubs(c.uuid).length > 0);
|
|
811
|
+
const work = mine.filter((c) => !coordinating.includes(c));
|
|
812
|
+
const unassigned = coordinating.length
|
|
813
|
+
? this.db.prepare(`SELECT * FROM tasks WHERE assignee IS NULL AND status = 'open' AND parent_id IN (${coordinating.map(() => "?").join(", ")}) ORDER BY ${PRIORITY_ORDER}, sort`)
|
|
814
|
+
.all(...coordinating.map((c) => c.uuid)).map(toCard)
|
|
815
|
+
: [];
|
|
816
|
+
const candidates = [...work.filter((c) => c.status === "open"), ...unassigned];
|
|
817
|
+
const unmet = candidates.map((card) => ({ card, waitsFor: this.unmetDeps(card).map((d) => d.code) }));
|
|
818
|
+
const newComments = [];
|
|
819
|
+
const sentBack = [];
|
|
820
|
+
for (const card of mine) {
|
|
821
|
+
const fresh = this.newComments(card);
|
|
822
|
+
const reject = [...fresh].reverse().find((e) => e.kind === "reject");
|
|
823
|
+
if (reject)
|
|
824
|
+
sentBack.push({ card, entry: reject });
|
|
825
|
+
const comments = fresh.filter((e) => e.kind === "comment");
|
|
826
|
+
if (comments.length)
|
|
827
|
+
newComments.push({ card, entries: comments });
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
doing: work.filter((c) => c.status === "claimed"),
|
|
831
|
+
blocked: work.filter((c) => c.status === "blocked"),
|
|
832
|
+
review: work.filter((c) => c.status === "review"),
|
|
833
|
+
ready: unmet.filter((x) => !x.waitsFor.length).map((x) => x.card),
|
|
834
|
+
waiting: unmet.filter((x) => x.waitsFor.length),
|
|
835
|
+
newComments,
|
|
836
|
+
sentBack,
|
|
837
|
+
mains: coordinating.map((card) => ({ card, progress: this.progress(card) })),
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
/** #2434: whether `actor` confirms `card` (finishes it from review): the person their cards, an agent's its dispatcher. */
|
|
841
|
+
confirms(card, actor) { return confirms(card, actor); }
|
|
842
|
+
/** #2434: the cards an instance handed out that wait in review for its confirmation (its `task mine`). */
|
|
843
|
+
toConfirm(name) {
|
|
844
|
+
return this.db.prepare("SELECT * FROM tasks WHERE status = 'review' AND needs_review = 1 AND (dispatcher_kind IS NULL OR dispatcher_kind != 'person') AND dispatcher = ? AND (assignee IS NULL OR assignee != ?) ORDER BY updated_at")
|
|
845
|
+
.all(name, name).map(toCard);
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* What waits for the person (step 3: Needs you, and the daily summary): cards in review that need their confirmation,
|
|
849
|
+
* and blocked cards whose question is theirs to answer — oldest first, each with when it started waiting (its last
|
|
850
|
+
* move to that status).
|
|
851
|
+
*
|
|
852
|
+
* A blocked card asks the person only when the person handed it out (dispatcher kind "person"). One an instance handed
|
|
853
|
+
* out is that instance's to decide (step 2's wakes: its question goes there, not here). One with no dispatcher is an
|
|
854
|
+
* instance's own "I'm waiting" — every card the `task` tool kept before the board, an agent's card for itself, a
|
|
855
|
+
* reviewer's "waiting on the author" — and is no question to the person: 2.3.0-beta.24 listed 28 such cards, weeks
|
|
856
|
+
* old, as "stuck, asking you" (#2425). Nothing is changed on the cards; they stay on the board as they are. One an
|
|
857
|
+
* instance handed out that was deleted since (`gone`) has nobody else to answer or confirm it: it falls back to the
|
|
858
|
+
* person.
|
|
859
|
+
*/
|
|
860
|
+
waitingForPerson(gone) {
|
|
861
|
+
const waiting = "((status = 'review' AND needs_review = 1) OR status = 'blocked')";
|
|
862
|
+
const rows = [
|
|
863
|
+
...this.db.prepare(`SELECT * FROM tasks WHERE dispatcher_kind = 'person' AND ${waiting}`).all().map(toCard),
|
|
864
|
+
...(gone ? this.db.prepare(`SELECT * FROM tasks WHERE dispatcher IS NOT NULL AND (dispatcher_kind IS NULL OR dispatcher_kind != 'person') AND ${waiting}`).all()
|
|
865
|
+
.map(toCard).filter((card) => orphaned(card, gone)) : []),
|
|
866
|
+
];
|
|
867
|
+
const moved = this.db.prepare("SELECT at FROM task_events WHERE task_id = ? AND type = 'moved' AND json_extract(data, '$.to') = ? ORDER BY id DESC LIMIT 1");
|
|
868
|
+
return rows.map((card) => {
|
|
869
|
+
const at = moved.get(card.uuid, card.status)?.at;
|
|
870
|
+
// Never updatedAt (#2425): a card edited every minute (a note, a heartbeat) would get a new `since` — and so a new
|
|
871
|
+
// Needs-you item id, posted again at the bottom as if it were new, once a minute.
|
|
872
|
+
return { card, since: at ?? card.submittedAt ?? card.createdAt };
|
|
873
|
+
}).sort((a, b) => a.since.localeCompare(b.since));
|
|
874
|
+
}
|
|
875
|
+
/** Cards by their numbers, in one read (the ones that exist; #2341 review: the cards a summary line named). */
|
|
876
|
+
cardsByCode(codes) {
|
|
877
|
+
const out = [];
|
|
878
|
+
for (let i = 0; i < codes.length; i += 500) {
|
|
879
|
+
const chunk = codes.slice(i, i + 500);
|
|
880
|
+
out.push(...this.db.prepare(`SELECT * FROM tasks WHERE code IN (${chunk.map(() => "?").join(", ")})`).all(...chunk).map(toCard));
|
|
881
|
+
}
|
|
882
|
+
return out;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* myWork() for many names in one read (the dashboard's Instances list, GET /ui/tasks/work): the live cards, every
|
|
886
|
+
* card's light row (sub tasks, dependencies) and the live cards' unseen entries, each read once — three statements
|
|
887
|
+
* however many names and cards (#2316 review: N myWork() calls cost tens of ms on the fleet's event loop). Same
|
|
888
|
+
* answers as myWork() name by name; tests/task-board-work-2316.test.ts pins both.
|
|
889
|
+
*/
|
|
890
|
+
workOf(names) {
|
|
891
|
+
const out = new Map();
|
|
892
|
+
if (!names.length)
|
|
893
|
+
return out;
|
|
894
|
+
const live = this.db.prepare(`SELECT * FROM tasks WHERE status IN ('open', 'claimed', 'blocked', 'review') ORDER BY ${STATUS_ORDER}, ${PRIORITY_ORDER}, sort`).all().map(toCard);
|
|
895
|
+
const all = new Map();
|
|
896
|
+
const subs = new Map();
|
|
897
|
+
for (const r of this.db.prepare("SELECT id, code, status, depends_on, parent_id FROM tasks").all()) {
|
|
898
|
+
const light = { code: r.code ?? r.id.slice(0, 8), status: r.status, deps: parseJsonArray(r.depends_on, []), parent: r.parent_id ?? null };
|
|
899
|
+
all.set(r.id, light);
|
|
900
|
+
if (light.parent && light.status !== "cancelled") {
|
|
901
|
+
const p = subs.get(light.parent) ?? { total: 0, open: 0, claimed: 0, blocked: 0, review: 0, done: 0 };
|
|
902
|
+
p.total++;
|
|
903
|
+
p[light.status]++;
|
|
904
|
+
subs.set(light.parent, p);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
// Unseen entries of the live cards: comments and rejections by anyone but the card's assignee, after what it saw.
|
|
908
|
+
const fresh = new Map();
|
|
909
|
+
for (const r of this.db.prepare(`SELECT e.* FROM task_entries e JOIN tasks t ON e.task_id = t.id
|
|
910
|
+
WHERE t.status IN ('open', 'claimed', 'blocked', 'review') AND e.id > t.seen_entry_id AND e.kind IN ('comment', 'reject') AND e.author IS NOT t.assignee ORDER BY e.id`).all()) {
|
|
911
|
+
const list = fresh.get(r.task_id) ?? [];
|
|
912
|
+
list.push({ id: Number(r.id), cardId: all.get(r.task_id)?.code ?? r.task_id.slice(0, 8), kind: r.kind, author: r.author,
|
|
913
|
+
authorKind: r.author_kind, text: r.text, at: r.at });
|
|
914
|
+
fresh.set(r.task_id, list);
|
|
915
|
+
}
|
|
916
|
+
/** unmetDeps(), on the light rows: own dependencies that exist and are not done, then the main task's not yet named. */
|
|
917
|
+
const unmet = (deps, parent) => {
|
|
918
|
+
const own = deps.filter((d) => { const dep = all.get(d); return !!dep && dep.status !== "done"; });
|
|
919
|
+
const main = parent ? all.get(parent) : undefined;
|
|
920
|
+
return main ? [...own, ...unmet(main.deps, main.parent).filter((d) => !own.includes(d))] : own;
|
|
921
|
+
};
|
|
922
|
+
const rank = { urgent: 0, high: 1, normal: 2 };
|
|
923
|
+
const byPriority = (a, b) => (rank[a.priority] ?? 3) - (rank[b.priority] ?? 3) || a.sort - b.sort;
|
|
924
|
+
const openUnassigned = live.filter((c) => c.assignee === null && c.status === "open");
|
|
925
|
+
for (const name of new Set(names)) {
|
|
926
|
+
const mine = live.filter((c) => c.assignee === name);
|
|
927
|
+
const coordinating = mine.filter((c) => c.kind === "main" && (subs.get(c.uuid)?.total ?? 0) > 0);
|
|
928
|
+
const work = mine.filter((c) => !coordinating.includes(c));
|
|
929
|
+
const theirs = new Set(coordinating.map((c) => c.uuid));
|
|
930
|
+
const unassigned = coordinating.length ? openUnassigned.filter((c) => c.parent !== null && theirs.has(c.parent)).sort(byPriority) : [];
|
|
931
|
+
const candidates = [...work.filter((c) => c.status === "open"), ...unassigned].map((card) => ({ card, waitsFor: unmet(card.deps, card.parent).map((d) => all.get(d).code) }));
|
|
932
|
+
const newComments = [];
|
|
933
|
+
const sentBack = [];
|
|
934
|
+
for (const card of mine) {
|
|
935
|
+
const entries = fresh.get(card.uuid) ?? [];
|
|
936
|
+
const reject = [...entries].reverse().find((e) => e.kind === "reject");
|
|
937
|
+
if (reject)
|
|
938
|
+
sentBack.push({ card, entry: reject });
|
|
939
|
+
const comments = entries.filter((e) => e.kind === "comment");
|
|
940
|
+
if (comments.length)
|
|
941
|
+
newComments.push({ card, entries: comments });
|
|
942
|
+
}
|
|
943
|
+
out.set(name, {
|
|
944
|
+
doing: work.filter((c) => c.status === "claimed"),
|
|
945
|
+
blocked: work.filter((c) => c.status === "blocked"),
|
|
946
|
+
review: work.filter((c) => c.status === "review"),
|
|
947
|
+
ready: candidates.filter((x) => !x.waitsFor.length).map((x) => x.card),
|
|
948
|
+
waiting: candidates.filter((x) => x.waitsFor.length),
|
|
949
|
+
newComments,
|
|
950
|
+
sentBack,
|
|
951
|
+
mains: coordinating.map((card) => ({ card, progress: { ...subs.get(card.uuid) } })),
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
return out;
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Step 3, the daily summary (task-board-summary.ts): what waits for the person now, what got done and what was
|
|
958
|
+
* opened in [since, until) (not cancelled), and every live main task's project and assignee.
|
|
959
|
+
*/
|
|
960
|
+
summaryRead(since, until, gone) {
|
|
961
|
+
const between = (sql) => this.db.prepare(sql).all(since, until).map(toCard);
|
|
962
|
+
return {
|
|
963
|
+
waiting: this.waitingForPerson(gone).map((w) => w.card),
|
|
964
|
+
done: between("SELECT * FROM tasks WHERE status = 'done' AND done_at >= ? AND done_at < ? ORDER BY sort"),
|
|
965
|
+
created: between("SELECT * FROM tasks WHERE status != 'cancelled' AND created_at >= ? AND created_at < ? ORDER BY sort"),
|
|
966
|
+
mains: this.db.prepare("SELECT project_id, assignee FROM tasks WHERE kind = 'main' AND status != 'cancelled'").all()
|
|
967
|
+
.map((r) => ({ projectId: r.project_id ?? null, assignee: r.assignee ?? null })),
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
// ── Writing: the board ──────────────────────────────────
|
|
971
|
+
createCards(actor, input) {
|
|
972
|
+
if (input.cards.length === 0)
|
|
973
|
+
throw new TaskBoardError("invalid", "no cards to create");
|
|
974
|
+
if (input.cards.length > BATCH_MAX)
|
|
975
|
+
throw new TaskBoardError("invalid", `at most ${BATCH_MAX} cards at a time`);
|
|
976
|
+
return this.tx(() => {
|
|
977
|
+
const created = [];
|
|
978
|
+
const mains = new Set();
|
|
979
|
+
input.cards.forEach((spec, i) => {
|
|
980
|
+
const which = input.cards.length > 1 ? `card ${i + 1}: ` : "";
|
|
981
|
+
try {
|
|
982
|
+
const parentRef = spec.parent !== undefined ? spec.parent : (input.parent ?? null);
|
|
983
|
+
const kind = spec.kind ?? (parentRef ? "sub" : "main");
|
|
984
|
+
let parent = null;
|
|
985
|
+
let projectId;
|
|
986
|
+
if (kind === "main") {
|
|
987
|
+
if (parentRef)
|
|
988
|
+
throw new TaskBoardError("invalid", "a main task has no parent; for a sub task use kind \"sub\"");
|
|
989
|
+
projectId = this.projectForMain(spec.project !== undefined ? spec.project : input.project);
|
|
990
|
+
}
|
|
991
|
+
else {
|
|
992
|
+
if (!parentRef)
|
|
993
|
+
throw new TaskBoardError("invalid", "a sub task needs its main task (parent)");
|
|
994
|
+
parent = this.resolveParent(this.resolveRef(parentRef, created), actor);
|
|
995
|
+
projectId = parent.projectId;
|
|
996
|
+
}
|
|
997
|
+
const title = checkText("the title", spec.title ?? "", CARD_LIMITS.title);
|
|
998
|
+
if (!title)
|
|
999
|
+
throw new TaskBoardError("invalid", "a card needs a title", "title_empty");
|
|
1000
|
+
if (kind === "sub" && spec.scope)
|
|
1001
|
+
throw new TaskBoardError("invalid", "scope is for main tasks only", "scope_main_only");
|
|
1002
|
+
const acceptance = checkAcceptance(spec.acceptance ?? []);
|
|
1003
|
+
if (acceptance.length > CARD_LIMITS.acceptanceItems)
|
|
1004
|
+
throw new TaskBoardError("too_long", `at most ${CARD_LIMITS.acceptanceItems} acceptance items`, "acceptance_max", [CARD_LIMITS.acceptanceItems]);
|
|
1005
|
+
const now = this.nowIso();
|
|
1006
|
+
const assignee = spec.assignee !== undefined ? normalizeAssignee(spec.assignee) : isPerson(actor) ? null : actor.name;
|
|
1007
|
+
const opener = isPerson(actor) ? { kind: "person", name: actor.name }
|
|
1008
|
+
: actor.kind === "instance" && assignee && assignee !== actor.name ? { kind: "instance", name: actor.name } : null;
|
|
1009
|
+
const dispatcher = spec.dispatcher !== undefined ? toDispatcher(spec.dispatcher, actor) : (parent?.dispatcher ?? opener);
|
|
1010
|
+
// #2434: a main task waits for the person's confirmation when the person handed it out; an agent's needs none
|
|
1011
|
+
// unless it asks for one (its dispatcher then confirms it).
|
|
1012
|
+
const needsReview = spec.needsReview ?? (kind === "main" && dispatcher?.kind === "person");
|
|
1013
|
+
const card = {
|
|
1014
|
+
uuid: randomUUID(),
|
|
1015
|
+
code: this.nextCode(kind === "main" ? "M" : "S"),
|
|
1016
|
+
kind,
|
|
1017
|
+
projectId,
|
|
1018
|
+
parent: parent?.uuid ?? null,
|
|
1019
|
+
title,
|
|
1020
|
+
status: "open",
|
|
1021
|
+
assignee,
|
|
1022
|
+
dispatcher,
|
|
1023
|
+
priority: spec.priority ?? "normal",
|
|
1024
|
+
size: spec.size ?? null,
|
|
1025
|
+
needsReview,
|
|
1026
|
+
onBoard: true,
|
|
1027
|
+
deps: this.checkDeps({ uuid: null, kind, parent: parent?.uuid ?? null }, (spec.dependsOn ?? []).map((d) => this.resolveRef(d, created))),
|
|
1028
|
+
goal: checkText("the goal", spec.goal ?? "", CARD_LIMITS.goal),
|
|
1029
|
+
scope: checkText("the scope", spec.scope ?? "", CARD_LIMITS.scope),
|
|
1030
|
+
acceptance,
|
|
1031
|
+
context: checkText("the context", spec.context ?? "", CARD_LIMITS.context),
|
|
1032
|
+
state: "",
|
|
1033
|
+
blockedReason: "",
|
|
1034
|
+
result: "",
|
|
1035
|
+
sort: this.nextSort(),
|
|
1036
|
+
createdBy: actor.name,
|
|
1037
|
+
version: 1,
|
|
1038
|
+
createdAt: now,
|
|
1039
|
+
updatedAt: now,
|
|
1040
|
+
startedAt: null,
|
|
1041
|
+
submittedAt: null,
|
|
1042
|
+
doneAt: null,
|
|
1043
|
+
seenEntryId: 0,
|
|
1044
|
+
};
|
|
1045
|
+
this.insert(card);
|
|
1046
|
+
this.codes.set(card.uuid, card.code);
|
|
1047
|
+
this.event(actor, card, "created", { kind, project: projectId, parent: parent?.code ?? null, title, assignee: card.assignee, ...(card.dispatcher ? { dispatcher: card.dispatcher.name } : {}) });
|
|
1048
|
+
created.push(card);
|
|
1049
|
+
if (card.parent)
|
|
1050
|
+
mains.add(card.parent);
|
|
1051
|
+
}
|
|
1052
|
+
catch (err) {
|
|
1053
|
+
if (err instanceof TaskBoardError)
|
|
1054
|
+
throw new TaskBoardError(err.code, `${which}${err.message}`, err.key, err.args);
|
|
1055
|
+
throw err;
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
for (const card of created)
|
|
1059
|
+
this.assertNoCycle(card.uuid);
|
|
1060
|
+
for (const uuid of mains)
|
|
1061
|
+
this.deriveMain(this.byUuid(uuid), actor);
|
|
1062
|
+
return { cards: created, ...this.spoken() };
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
updateCard(actor, ref, patch) {
|
|
1066
|
+
return this.tx(() => {
|
|
1067
|
+
const card = this.getCard(ref);
|
|
1068
|
+
this.checkVersion(card, patch.version);
|
|
1069
|
+
const ai = !isPerson(actor);
|
|
1070
|
+
const editsContent = ["title", "goal", "scope", "context", "state", "result", "blockedReason", "size", "priority", "needsReview", "dependsOn", "acceptance", "addAcceptance", "check", "uncheck", "dispatcher"]
|
|
1071
|
+
.some((k) => patch[k] !== undefined);
|
|
1072
|
+
if (ai && editsContent && !this.canEdit(card, actor)) {
|
|
1073
|
+
throw new TaskBoardError("forbidden", `${card.code} belongs to ${card.assignee}; you can only comment on it`);
|
|
1074
|
+
}
|
|
1075
|
+
const changes = {};
|
|
1076
|
+
const set = (key, value, shown = key, show) => {
|
|
1077
|
+
if (JSON.stringify(card[key]) !== JSON.stringify(value)) {
|
|
1078
|
+
changes[shown] = { from: show ? show(card[key]) : card[key], to: show ? show(value) : value };
|
|
1079
|
+
card[key] = value;
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
if (patch.title !== undefined) {
|
|
1083
|
+
const title = checkText("the title", patch.title, CARD_LIMITS.title);
|
|
1084
|
+
if (!title)
|
|
1085
|
+
throw new TaskBoardError("invalid", "the title cannot be empty", "title_empty");
|
|
1086
|
+
set("title", title);
|
|
1087
|
+
}
|
|
1088
|
+
if (patch.goal !== undefined)
|
|
1089
|
+
set("goal", checkText("the goal", patch.goal, CARD_LIMITS.goal));
|
|
1090
|
+
if (patch.scope !== undefined) {
|
|
1091
|
+
if (card.kind !== "main")
|
|
1092
|
+
throw new TaskBoardError("invalid", "scope is for main tasks only", "scope_main_only");
|
|
1093
|
+
set("scope", checkText("the scope", patch.scope, CARD_LIMITS.scope));
|
|
1094
|
+
}
|
|
1095
|
+
if (patch.context !== undefined)
|
|
1096
|
+
set("context", checkText("the context", patch.context, CARD_LIMITS.context));
|
|
1097
|
+
if (patch.state !== undefined)
|
|
1098
|
+
set("state", checkState(patch.state));
|
|
1099
|
+
if (patch.result !== undefined)
|
|
1100
|
+
set("result", checkText("the result", patch.result, CARD_LIMITS.result));
|
|
1101
|
+
if (patch.blockedReason !== undefined) {
|
|
1102
|
+
if (card.status !== "blocked")
|
|
1103
|
+
throw new TaskBoardError("invalid", "only a blocked card has a blocked reason", "blocked_reason_not_blocked");
|
|
1104
|
+
const reason = checkText("the blocked reason", patch.blockedReason, CARD_LIMITS.blockedReason);
|
|
1105
|
+
if (!reason)
|
|
1106
|
+
throw new TaskBoardError("invalid", "the blocked reason cannot be empty", "block_reason");
|
|
1107
|
+
set("blockedReason", reason);
|
|
1108
|
+
}
|
|
1109
|
+
if (patch.size !== undefined)
|
|
1110
|
+
set("size", patch.size);
|
|
1111
|
+
if (patch.priority !== undefined)
|
|
1112
|
+
set("priority", patch.priority);
|
|
1113
|
+
if (patch.dispatcher !== undefined) {
|
|
1114
|
+
const next = toDispatcher(patch.dispatcher, actor);
|
|
1115
|
+
// #2434: the person's card stays theirs — an instance re-dispatching it would take it out of their confirmation.
|
|
1116
|
+
const moved = JSON.stringify(next) !== JSON.stringify(card.dispatcher);
|
|
1117
|
+
if (ai && personConfirms(card) && moved) {
|
|
1118
|
+
throw new TaskBoardError("forbidden", `${card.code} was handed out by the person; only they hand it to someone else`);
|
|
1119
|
+
}
|
|
1120
|
+
// #2436 review: nor an agent's card that waits for its dispatcher's confirmation — dropping or replacing the
|
|
1121
|
+
// dispatcher would let its assignee confirm it itself. Only that dispatcher (or a person) hands it on.
|
|
1122
|
+
if (ai && moved && card.needsReview && card.dispatcher?.kind === "instance" && card.dispatcher.name !== actor.name) {
|
|
1123
|
+
throw new TaskBoardError("forbidden", `${card.code} waits for ${card.dispatcher.name}'s confirmation; only they hand it to someone else`);
|
|
1124
|
+
}
|
|
1125
|
+
set("dispatcher", next, "dispatcher", (d) => d?.name ?? null);
|
|
1126
|
+
}
|
|
1127
|
+
if (patch.needsReview !== undefined) {
|
|
1128
|
+
if (ai && card.needsReview && !patch.needsReview && !confirms(card, actor)) {
|
|
1129
|
+
throw new TaskBoardError("forbidden", personConfirms(card) ? "only a person can clear \"needs review\"" : `only ${confirmerOf(card)} can clear "needs review" on ${card.code}`);
|
|
1130
|
+
}
|
|
1131
|
+
set("needsReview", patch.needsReview);
|
|
1132
|
+
}
|
|
1133
|
+
if (patch.dependsOn !== undefined) {
|
|
1134
|
+
const next = this.checkDeps(card, patch.dependsOn);
|
|
1135
|
+
if (ai && card.createdBy !== actor.name) {
|
|
1136
|
+
const dropped = card.deps.filter((d) => !next.includes(d)).map((d) => this.byUuid(d)).filter((c) => !!c && c.status !== "done");
|
|
1137
|
+
if (dropped.length) {
|
|
1138
|
+
throw new TaskBoardError("forbidden", `you cannot drop dependencies that are not done (${dropped.map((c) => c.code).join(", ")}); ask whoever opened the card, or the person`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
set("deps", next, "dependsOn", (v) => v.map((d) => this.codeOf(d)));
|
|
1142
|
+
}
|
|
1143
|
+
if (patch.projectId !== undefined && patch.projectId !== card.projectId) {
|
|
1144
|
+
if (ai)
|
|
1145
|
+
throw new TaskBoardError("forbidden", "only a person can move a main task to another project");
|
|
1146
|
+
if (card.kind !== "main")
|
|
1147
|
+
throw new TaskBoardError("invalid", "a sub task follows its main task's project; move the main task", "sub_project");
|
|
1148
|
+
const projectId = this.projectForMain(patch.projectId);
|
|
1149
|
+
set("projectId", projectId);
|
|
1150
|
+
this.db.prepare("UPDATE tasks SET project_id = ? WHERE parent_id = ?").run(projectId, card.uuid);
|
|
1151
|
+
}
|
|
1152
|
+
if (patch.acceptance !== undefined || patch.addAcceptance !== undefined || patch.check !== undefined || patch.uncheck !== undefined) {
|
|
1153
|
+
let acceptance = patch.acceptance !== undefined ? checkAcceptance(patch.acceptance, card.acceptance) : card.acceptance.map((a) => ({ ...a }));
|
|
1154
|
+
if (patch.addAcceptance)
|
|
1155
|
+
acceptance = [...acceptance, ...checkAcceptance(patch.addAcceptance)];
|
|
1156
|
+
if (acceptance.length > CARD_LIMITS.acceptanceItems)
|
|
1157
|
+
throw new TaskBoardError("too_long", `at most ${CARD_LIMITS.acceptanceItems} acceptance items`, "acceptance_max", [CARD_LIMITS.acceptanceItems]);
|
|
1158
|
+
const mark = (indexes, done) => {
|
|
1159
|
+
for (const n of indexes ?? []) {
|
|
1160
|
+
if (!Number.isInteger(n) || n < 1 || n > acceptance.length)
|
|
1161
|
+
throw new TaskBoardError("invalid", `there is no acceptance item ${n} (there are ${acceptance.length})`, "acceptance_no_item", [n, acceptance.length]);
|
|
1162
|
+
acceptance[n - 1].done = done;
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
mark(patch.check, true);
|
|
1166
|
+
mark(patch.uncheck, false);
|
|
1167
|
+
set("acceptance", acceptance);
|
|
1168
|
+
}
|
|
1169
|
+
let touched = Object.keys(changes).length > 0;
|
|
1170
|
+
if (patch.assignee !== undefined) {
|
|
1171
|
+
const next = normalizeAssignee(patch.assignee);
|
|
1172
|
+
if (next !== card.assignee) {
|
|
1173
|
+
if (ai && !(card.assignee === null || card.assignee === actor.name || card.createdBy === actor.name)) {
|
|
1174
|
+
throw new TaskBoardError("forbidden", `${card.code} belongs to ${card.assignee}; you cannot reassign it`);
|
|
1175
|
+
}
|
|
1176
|
+
this.assign(card, next, actor);
|
|
1177
|
+
touched = true;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
if (patch.log !== undefined) {
|
|
1181
|
+
const text = checkText("the log line", patch.log.replace(/\s*\n\s*/g, " "), CARD_LIMITS.log);
|
|
1182
|
+
if (text) {
|
|
1183
|
+
this.entry(card, "log", actor, text);
|
|
1184
|
+
this.event(actor, card, "logged", { text });
|
|
1185
|
+
touched = true;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
if (patch.comment !== undefined) {
|
|
1189
|
+
const text = checkText("the comment", patch.comment, CARD_LIMITS.comment);
|
|
1190
|
+
if (text) {
|
|
1191
|
+
this.entry(card, "comment", actor, text);
|
|
1192
|
+
this.event(actor, card, "commented", { text });
|
|
1193
|
+
touched = true;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
if (!touched)
|
|
1197
|
+
return { card, notes: ["no change"], said: [] };
|
|
1198
|
+
card.version++;
|
|
1199
|
+
card.updatedAt = this.nowIso();
|
|
1200
|
+
this.surfaceComments(card, actor);
|
|
1201
|
+
this.markSeen(card, actor);
|
|
1202
|
+
this.save(card);
|
|
1203
|
+
if (changes.dependsOn)
|
|
1204
|
+
this.assertNoCycle(card.uuid);
|
|
1205
|
+
if (Object.keys(changes).length)
|
|
1206
|
+
this.event(actor, card, "updated", { changes });
|
|
1207
|
+
if (changes.acceptance && card.status === "claimed" && card.acceptance.every((a) => a.done)) {
|
|
1208
|
+
this.say("acceptance_all", [card.code, requiresReview(card) ? "review" : "done"], `every acceptance item is checked: ${card.code} can move to ${requiresReview(card) ? "review" : "done"}`);
|
|
1209
|
+
}
|
|
1210
|
+
return { card, ...this.spoken() };
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
moveCard(actor, ref, to, options = {}) {
|
|
1214
|
+
return this.tx(() => {
|
|
1215
|
+
const card = this.getCard(ref);
|
|
1216
|
+
this.checkVersion(card, options.version);
|
|
1217
|
+
const from = card.status;
|
|
1218
|
+
if (from === to)
|
|
1219
|
+
throw new TaskBoardError("invalid", `${card.code} is already ${to}`, "already", [card.code, to]);
|
|
1220
|
+
const reason = checkText("the reason", options.reason ?? "", CARD_LIMITS.reason);
|
|
1221
|
+
if (options.result !== undefined) {
|
|
1222
|
+
const result = checkText("the result", options.result, CARD_LIMITS.result);
|
|
1223
|
+
if (result !== card.result) {
|
|
1224
|
+
this.event(actor, card, "updated", { changes: { result: { from: card.result, to: result } } });
|
|
1225
|
+
card.result = result;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
// Back from blocked a reason has nothing else to be: it is the answer, a comment its assignee reads.
|
|
1229
|
+
const comment = options.comment ?? (from === "blocked" && to === "claimed" && reason ? reason : undefined);
|
|
1230
|
+
const derived = card.kind === "main" && this.activeSubs(card.uuid).length > 0;
|
|
1231
|
+
// A dispatcher's answer does not start work its assignee has no room for (#2303 review): the answer is kept on the
|
|
1232
|
+
// card as a comment the assignee reads, and the card waits, blocked, until the assignee resumes it.
|
|
1233
|
+
if (from === "blocked" && to === "claimed" && !derived && card.assignee && card.assignee !== actor.name && isDispatcher(card, actor) && this.isOnBoard(card)) {
|
|
1234
|
+
this.checkAnswer(card, comment);
|
|
1235
|
+
const busy = this.busyWith(card, card.assignee);
|
|
1236
|
+
if (busy.length) {
|
|
1237
|
+
const text = checkText("the comment", comment, CARD_LIMITS.comment);
|
|
1238
|
+
this.entry(card, "comment", actor, text);
|
|
1239
|
+
this.event(actor, card, "commented", { text });
|
|
1240
|
+
card.version++;
|
|
1241
|
+
card.updatedAt = this.nowIso();
|
|
1242
|
+
this.save(card);
|
|
1243
|
+
const codes = busy.map((c) => c.code).join(", ");
|
|
1244
|
+
this.say("answer_kept", [card.assignee, codes, card.code], `${card.assignee} is working on ${codes}: your answer is on ${card.code} as a comment, and it stays blocked until ${card.assignee} resumes it`);
|
|
1245
|
+
return { card, from, ...this.spoken() };
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
if (derived)
|
|
1249
|
+
this.checkDerivedMove(card, actor, to, reason, { comment });
|
|
1250
|
+
else
|
|
1251
|
+
this.checkManualMove(card, actor, to, reason, { comment });
|
|
1252
|
+
this.applyMove(card, actor, to, reason, comment);
|
|
1253
|
+
return { card, from, ...this.spoken() };
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Place a card between two neighbours of its column (`after` comes right before it, `before` right after it).
|
|
1258
|
+
* Order is presentation only, so the version does not change.
|
|
1259
|
+
*/
|
|
1260
|
+
reorder(actor, ref, after, before) {
|
|
1261
|
+
return this.tx(() => {
|
|
1262
|
+
const card = this.getCard(ref);
|
|
1263
|
+
const a = after ? this.getCard(after).sort : null;
|
|
1264
|
+
const b = before ? this.getCard(before).sort : null;
|
|
1265
|
+
let sort;
|
|
1266
|
+
if (a !== null && b !== null) {
|
|
1267
|
+
// Halving the gap again and again runs out of numbers: spread every card out again first.
|
|
1268
|
+
if (Math.abs(b - a) < 1e-6) {
|
|
1269
|
+
this.renumber();
|
|
1270
|
+
return this.reorder(actor, ref, after, before);
|
|
1271
|
+
}
|
|
1272
|
+
sort = (a + b) / 2;
|
|
1273
|
+
}
|
|
1274
|
+
else if (a !== null)
|
|
1275
|
+
sort = a + 1;
|
|
1276
|
+
else if (b !== null)
|
|
1277
|
+
sort = b - 1;
|
|
1278
|
+
else
|
|
1279
|
+
return card;
|
|
1280
|
+
if (sort === card.sort)
|
|
1281
|
+
return card;
|
|
1282
|
+
card.sort = sort;
|
|
1283
|
+
this.save(card);
|
|
1284
|
+
this.event(actor, card, "reordered", {});
|
|
1285
|
+
return card;
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
// ── Writing: the fleet's `task` tool (and the old /ui/tasks routes) ──
|
|
1289
|
+
/**
|
|
1290
|
+
* A task as the `task` tool has always opened it: a main task under no project, assigned only when asked, with no
|
|
1291
|
+
* review gate unless a person later sets one. Dependencies are stored as given (ids are not checked).
|
|
1292
|
+
*/
|
|
1293
|
+
legacyCreate(actor, params) {
|
|
1294
|
+
return this.tx(() => {
|
|
1295
|
+
const now = this.nowIso();
|
|
1296
|
+
const card = {
|
|
1297
|
+
uuid: randomUUID(), code: this.nextCode("M"), kind: "main", projectId: null, parent: null, title: params.title, status: "open",
|
|
1298
|
+
assignee: params.assignee ?? null, priority: params.priority ?? "normal", size: null, needsReview: false, onBoard: false,
|
|
1299
|
+
// Opened for another instance: the opener handed it out (its blocked question goes to it).
|
|
1300
|
+
dispatcher: actor.kind === "instance" && params.assignee && params.assignee !== actor.name ? { kind: "instance", name: actor.name } : null,
|
|
1301
|
+
deps: this.checkDeps({ uuid: null, kind: "main", parent: null }, params.depends_on ?? []),
|
|
1302
|
+
goal: params.description ?? "", scope: "", acceptance: [], context: "", state: "", blockedReason: "", result: "", sort: this.nextSort(),
|
|
1303
|
+
createdBy: actor.name, version: 1, createdAt: now, updatedAt: now, startedAt: null, submittedAt: null, doneAt: null, seenEntryId: 0,
|
|
1304
|
+
};
|
|
1305
|
+
this.insert(card);
|
|
1306
|
+
this.codes.set(card.uuid, card.code);
|
|
1307
|
+
this.event(actor, card, "created", { kind: "main", project: null, parent: null, title: card.title, assignee: card.assignee });
|
|
1308
|
+
return card;
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Whether the board's rules hold for the `task` tool on this card: it is in a project, is a sub task, has sub tasks,
|
|
1313
|
+
* or waits for the person. The tool's own tasks outside all of that keep the tool's old leniency (anyone may move
|
|
1314
|
+
* them), and a person is never held to an instance's rules.
|
|
1315
|
+
*/
|
|
1316
|
+
heldToBoardRules(card, actor) {
|
|
1317
|
+
return !isPerson(actor) && this.isOnBoard(card);
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* `task claim`: an open task, every dependency done (a missing one counts as done). On the board, an instance claims
|
|
1321
|
+
* as it would there: an unassigned card or its own, its main task's dependencies done too.
|
|
1322
|
+
*/
|
|
1323
|
+
legacyClaim(actor, uuid, assignee) {
|
|
1324
|
+
return this.tx(() => {
|
|
1325
|
+
const card = this.byUuid(uuid);
|
|
1326
|
+
if (!card)
|
|
1327
|
+
throw new Error(`Task "${uuid}" not found`);
|
|
1328
|
+
if (card.status !== "open")
|
|
1329
|
+
throw new Error(`Task "${uuid}" is ${card.status}, cannot claim`);
|
|
1330
|
+
if (card.kind === "main" && this.activeSubs(card.uuid).length)
|
|
1331
|
+
throw new Error(`Task "${card.code}" has sub tasks; claim one of them instead`);
|
|
1332
|
+
if (this.heldToBoardRules(card, actor)) {
|
|
1333
|
+
if (assignee !== actor.name)
|
|
1334
|
+
throw new TaskBoardError("forbidden", `an instance claims ${card.code} for itself, not for ${assignee}`);
|
|
1335
|
+
this.checkManualMove(card, actor, "claimed", "");
|
|
1336
|
+
}
|
|
1337
|
+
else {
|
|
1338
|
+
for (const depId of card.deps) {
|
|
1339
|
+
const dep = this.byUuid(depId);
|
|
1340
|
+
if (dep && dep.status !== "done")
|
|
1341
|
+
throw new Error(`Task "${uuid}" is blocked by "${depId}" (status: ${dep.status})`);
|
|
1342
|
+
}
|
|
1343
|
+
if (assignee !== card.assignee)
|
|
1344
|
+
this.assign(card, assignee, actor);
|
|
1345
|
+
}
|
|
1346
|
+
this.applyMove(card, actor, "claimed", "");
|
|
1347
|
+
return card;
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* `task done`: from open or claimed. A card that needs confirmation goes to review instead, so no instance finishes
|
|
1352
|
+
* one it does not confirm alone; the one who confirms it finishes it, from review too (#2434). On the board, an instance finishes as it would there: its own card, with a result
|
|
1353
|
+
* (an open one is claimed first, under the same rules).
|
|
1354
|
+
*/
|
|
1355
|
+
legacyComplete(actor, uuid, result) {
|
|
1356
|
+
return this.tx(() => {
|
|
1357
|
+
const card = this.byUuid(uuid);
|
|
1358
|
+
if (!card)
|
|
1359
|
+
throw new Error(`Task "${uuid}" not found`);
|
|
1360
|
+
// #2434: a card in review is finished by whoever confirms it (an agent's card: its dispatcher).
|
|
1361
|
+
const confirming = card.status === "review" && confirms(card, actor);
|
|
1362
|
+
if (card.status !== "claimed" && card.status !== "open" && !confirming) {
|
|
1363
|
+
throw new Error(`Task "${uuid}" is ${card.status}, cannot complete (must be open or claimed${card.status === "review" ? `; it waits for ${confirmerOf(card)}'s confirmation` : ""})`);
|
|
1364
|
+
}
|
|
1365
|
+
const open = this.children(card.uuid).filter((k) => !isFinished(k));
|
|
1366
|
+
if (open.length)
|
|
1367
|
+
throw new Error(`Task "${card.code}" still has unfinished sub tasks: ${open.map((k) => k.code).join(", ")}`);
|
|
1368
|
+
if (result !== undefined && result !== card.result) {
|
|
1369
|
+
this.event(actor, card, "updated", { changes: { result: { from: card.result, to: result } } });
|
|
1370
|
+
card.result = result;
|
|
1371
|
+
}
|
|
1372
|
+
const to = requiresReview(card) && !confirms(card, actor) ? "review" : "done";
|
|
1373
|
+
if (this.heldToBoardRules(card, actor)) {
|
|
1374
|
+
if (card.status === "open") {
|
|
1375
|
+
this.checkManualMove(card, actor, "claimed", "", { finishing: true });
|
|
1376
|
+
this.applyMove(card, actor, "claimed", "");
|
|
1377
|
+
}
|
|
1378
|
+
this.checkManualMove(card, actor, to, "");
|
|
1379
|
+
}
|
|
1380
|
+
this.applyMove(card, actor, to, "");
|
|
1381
|
+
return card;
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* `task update`: status, assignee, result, priority. The review gate holds for everyone but a person: "done" on a
|
|
1386
|
+
* card that needs confirmation becomes "review", and only a person takes a card out of review to done. On the
|
|
1387
|
+
* board, an instance is held to the board's rules for each change (its own cards; moves as on the board).
|
|
1388
|
+
*/
|
|
1389
|
+
legacyUpdate(actor, uuid, params) {
|
|
1390
|
+
return this.tx(() => {
|
|
1391
|
+
const card = this.byUuid(uuid);
|
|
1392
|
+
if (!card)
|
|
1393
|
+
throw new Error(`Task "${uuid}" not found`);
|
|
1394
|
+
const held = this.heldToBoardRules(card, actor);
|
|
1395
|
+
const derived = card.kind === "main" && this.activeSubs(card.uuid).length > 0;
|
|
1396
|
+
if (held && (params.result !== undefined || params.priority !== undefined) && !this.canEdit(card, actor)) {
|
|
1397
|
+
throw new TaskBoardError("forbidden", `${card.code} belongs to ${card.assignee}; you can only comment on it`);
|
|
1398
|
+
}
|
|
1399
|
+
let touched = false;
|
|
1400
|
+
if (params.result !== undefined && params.result !== card.result) {
|
|
1401
|
+
this.event(actor, card, "updated", { changes: { result: { from: card.result, to: params.result } } });
|
|
1402
|
+
card.result = params.result;
|
|
1403
|
+
touched = true;
|
|
1404
|
+
}
|
|
1405
|
+
if (params.priority !== undefined && params.priority !== card.priority) {
|
|
1406
|
+
this.event(actor, card, "updated", { changes: { priority: { from: card.priority, to: params.priority } } });
|
|
1407
|
+
card.priority = params.priority;
|
|
1408
|
+
touched = true;
|
|
1409
|
+
}
|
|
1410
|
+
if (params.assignee !== undefined && params.assignee !== card.assignee) {
|
|
1411
|
+
if (held && !(card.assignee === null || card.assignee === actor.name || card.createdBy === actor.name)) {
|
|
1412
|
+
throw new TaskBoardError("forbidden", `${card.code} belongs to ${card.assignee}; you cannot reassign it`);
|
|
1413
|
+
}
|
|
1414
|
+
this.assign(card, params.assignee, actor);
|
|
1415
|
+
touched = true;
|
|
1416
|
+
}
|
|
1417
|
+
if (params.status !== undefined && params.status !== card.status) {
|
|
1418
|
+
let to = params.status;
|
|
1419
|
+
if (!isPerson(actor)) {
|
|
1420
|
+
if (derived && !held)
|
|
1421
|
+
throw new Error(`Task "${card.code}" has sub tasks; its status follows them`);
|
|
1422
|
+
if (derived)
|
|
1423
|
+
this.checkDerivedMove(card, actor, to, "");
|
|
1424
|
+
else {
|
|
1425
|
+
if (card.status === "review" && to === "done" && !confirms(card, actor))
|
|
1426
|
+
throw new Error(`Task "${card.code}" is waiting for ${confirmerOf(card)}'s confirmation`);
|
|
1427
|
+
if (to === "done" && card.status !== "review" && requiresReview(card) && !confirms(card, actor))
|
|
1428
|
+
to = "review";
|
|
1429
|
+
if (held && to !== card.status)
|
|
1430
|
+
this.checkManualMove(card, actor, to, "");
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (to !== card.status) {
|
|
1434
|
+
this.applyMove(card, actor, to, "");
|
|
1435
|
+
return card;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
if (touched) {
|
|
1439
|
+
card.version++;
|
|
1440
|
+
card.updatedAt = this.nowIso();
|
|
1441
|
+
// As on the board: its assignee's write hands over the comments it had not seen, then they count as seen.
|
|
1442
|
+
this.surfaceComments(card, actor);
|
|
1443
|
+
this.markSeen(card, actor);
|
|
1444
|
+
this.save(card);
|
|
1445
|
+
}
|
|
1446
|
+
return card;
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
// ── Rules ───────────────────────────────────────────────
|
|
1450
|
+
/**
|
|
1451
|
+
* Cards moved by hand: sub tasks, and main tasks that have no sub tasks. `comment` is the move's own comment (a
|
|
1452
|
+
* dispatcher's answer); `finishing`: a claim on the way to done (the `task` tool's done of an open card).
|
|
1453
|
+
*/
|
|
1454
|
+
checkManualMove(card, actor, to, reason, opts = {}) {
|
|
1455
|
+
const ai = !isPerson(actor);
|
|
1456
|
+
const from = card.status;
|
|
1457
|
+
const own = card.assignee === actor.name;
|
|
1458
|
+
const startsWork = ai && !opts.finishing && this.isOnBoard(card);
|
|
1459
|
+
if (card.kind === "sub" && to !== "cancelled") {
|
|
1460
|
+
const main = card.parent ? this.byUuid(card.parent) : undefined;
|
|
1461
|
+
if (main?.status === "cancelled")
|
|
1462
|
+
throw new TaskBoardError("invalid", `main task ${main.code} is cancelled; restore it first to keep working on this`, "main_cancelled", [main.code]);
|
|
1463
|
+
}
|
|
1464
|
+
const notOwner = () => new TaskBoardError("forbidden", `${card.code} belongs to ${card.assignee ?? "nobody"}; you cannot move it`);
|
|
1465
|
+
switch (to) {
|
|
1466
|
+
case "claimed": {
|
|
1467
|
+
if (from === "open") {
|
|
1468
|
+
const unmet = this.unmetDeps(card);
|
|
1469
|
+
if (ai) {
|
|
1470
|
+
if (card.assignee && !own)
|
|
1471
|
+
throw new TaskBoardError("forbidden", `${card.code} is assigned to ${card.assignee}; you cannot take it`);
|
|
1472
|
+
if (unmet.length) {
|
|
1473
|
+
throw new TaskBoardError("blocked_by_deps", `${card.code} waits for ${unmet.map((c) => `${c.code} (${c.status})`).join(", ")}; do those first or take another card`);
|
|
1474
|
+
}
|
|
1475
|
+
if (startsWork)
|
|
1476
|
+
this.checkOneAtATime(card, actor);
|
|
1477
|
+
if (!card.assignee)
|
|
1478
|
+
this.assign(card, actor.name, actor);
|
|
1479
|
+
}
|
|
1480
|
+
else if (unmet.length) {
|
|
1481
|
+
this.say("deps_unmet", [unmet.map((c) => c.code).join(", ")], `note: ${unmet.map((c) => c.code).join(", ")} ${unmet.length === 1 ? "is" : "are"} not done yet`);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
else if (from === "blocked") {
|
|
1485
|
+
if (ai && !own) {
|
|
1486
|
+
if (!isDispatcher(card, actor))
|
|
1487
|
+
throw notOwner();
|
|
1488
|
+
this.checkAnswer(card, opts.comment);
|
|
1489
|
+
}
|
|
1490
|
+
else if (startsWork)
|
|
1491
|
+
this.checkOneAtATime(card, actor);
|
|
1492
|
+
}
|
|
1493
|
+
else if (from === "review") {
|
|
1494
|
+
if (ai && !own && confirms(card, actor)) {
|
|
1495
|
+
// #2434: an agent's card sent back by the instance that confirms it, as the person sends theirs back.
|
|
1496
|
+
if (!reason)
|
|
1497
|
+
throw new TaskBoardError("invalid", "sending work back needs a reason", "sendback_reason");
|
|
1498
|
+
this.entry(card, "reject", actor, reason);
|
|
1499
|
+
}
|
|
1500
|
+
else if (ai) {
|
|
1501
|
+
if (!own)
|
|
1502
|
+
throw notOwner();
|
|
1503
|
+
if (startsWork)
|
|
1504
|
+
this.checkOneAtATime(card, actor);
|
|
1505
|
+
// Out of the person's queue: they read why.
|
|
1506
|
+
if (!reason)
|
|
1507
|
+
throw new TaskBoardError("invalid", "taking a card back from review needs a reason (the person reads it)");
|
|
1508
|
+
this.entry(card, "comment", actor, `withdrawn from review: ${reason}`);
|
|
1509
|
+
}
|
|
1510
|
+
else {
|
|
1511
|
+
if (!reason)
|
|
1512
|
+
throw new TaskBoardError("invalid", "sending work back needs a reason", "sendback_reason");
|
|
1513
|
+
this.entry(card, "reject", actor, reason);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
else if (ai) {
|
|
1517
|
+
throw new TaskBoardError("forbidden", `an instance cannot reopen a ${from} card; open a new one if needed`);
|
|
1518
|
+
}
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
case "blocked": {
|
|
1522
|
+
if (ai) {
|
|
1523
|
+
if (from !== "claimed")
|
|
1524
|
+
throw new TaskBoardError("invalid", `only a card in progress can be blocked (${card.code} is ${from})`);
|
|
1525
|
+
if (!own)
|
|
1526
|
+
throw notOwner();
|
|
1527
|
+
}
|
|
1528
|
+
if (!reason)
|
|
1529
|
+
throw new TaskBoardError("invalid", "blocking needs a reason: who has to answer what", "block_reason");
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
case "review": {
|
|
1533
|
+
if (ai) {
|
|
1534
|
+
if (from !== "claimed")
|
|
1535
|
+
throw new TaskBoardError("invalid", `only a card in progress can go to review (${card.code} is ${from})`);
|
|
1536
|
+
if (!own)
|
|
1537
|
+
throw notOwner();
|
|
1538
|
+
}
|
|
1539
|
+
this.checkFinish(card, ai, reason);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
case "done": {
|
|
1543
|
+
if (ai) {
|
|
1544
|
+
if (from === "review") {
|
|
1545
|
+
if (!confirms(card, actor)) {
|
|
1546
|
+
throw new TaskBoardError("forbidden", personConfirms(card) ? `${card.code} is waiting for the person's confirmation: only they finish it`
|
|
1547
|
+
: `${card.code} is waiting for ${confirmerOf(card)}'s confirmation: only they finish it`);
|
|
1548
|
+
}
|
|
1549
|
+
if (reason)
|
|
1550
|
+
this.entry(card, "approve", actor, reason);
|
|
1551
|
+
}
|
|
1552
|
+
else {
|
|
1553
|
+
if (from !== "claimed")
|
|
1554
|
+
throw new TaskBoardError("invalid", `only a card in progress can be done (${card.code} is ${from})`);
|
|
1555
|
+
if (!own)
|
|
1556
|
+
throw notOwner();
|
|
1557
|
+
if (requiresReview(card) && !confirms(card, actor)) {
|
|
1558
|
+
throw new TaskBoardError("needs_review", personConfirms(card) ? `${card.code} needs the person's confirmation; move it to review`
|
|
1559
|
+
: `${card.code} needs ${confirmerOf(card)}'s confirmation; move it to review`);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
else if (from === "review" && reason) {
|
|
1564
|
+
this.entry(card, "approve", actor, reason);
|
|
1565
|
+
}
|
|
1566
|
+
this.checkFinish(card, ai, reason);
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
case "open": {
|
|
1570
|
+
if (ai) {
|
|
1571
|
+
if (from !== "claimed" && from !== "blocked")
|
|
1572
|
+
throw new TaskBoardError("invalid", "only a card in progress or blocked can go back to open");
|
|
1573
|
+
if (!own)
|
|
1574
|
+
throw notOwner();
|
|
1575
|
+
this.assign(card, null, actor);
|
|
1576
|
+
}
|
|
1577
|
+
else if (from === "review") {
|
|
1578
|
+
// Sending back from review is a rejection either way, so the assignee hears why.
|
|
1579
|
+
if (!reason)
|
|
1580
|
+
throw new TaskBoardError("invalid", "sending work back needs a reason", "sendback_reason");
|
|
1581
|
+
this.entry(card, "reject", actor, reason);
|
|
1582
|
+
}
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
case "cancelled": {
|
|
1586
|
+
if (ai) {
|
|
1587
|
+
if (card.createdBy !== actor.name)
|
|
1588
|
+
throw new TaskBoardError("forbidden", "an instance can cancel only the cards it opened");
|
|
1589
|
+
if (!reason)
|
|
1590
|
+
throw new TaskBoardError("invalid", "cancelling needs a reason");
|
|
1591
|
+
}
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* A main task with sub tasks follows them. By hand: block and unblock it, resubmit it after a rejection (its owner),
|
|
1598
|
+
* and for a person approve, reject, reopen, cancel and restore it.
|
|
1599
|
+
*/
|
|
1600
|
+
checkDerivedMove(card, actor, to, reason, opts = {}) {
|
|
1601
|
+
const ai = !isPerson(actor);
|
|
1602
|
+
const from = card.status;
|
|
1603
|
+
const own = card.assignee === actor.name;
|
|
1604
|
+
const notOwner = () => new TaskBoardError("forbidden", `${card.code} belongs to ${card.assignee ?? "nobody"}`);
|
|
1605
|
+
if (to === "cancelled") {
|
|
1606
|
+
if (ai && card.createdBy !== actor.name)
|
|
1607
|
+
throw new TaskBoardError("forbidden", "an instance can cancel only the main tasks it opened");
|
|
1608
|
+
if (ai && !reason)
|
|
1609
|
+
throw new TaskBoardError("invalid", "cancelling needs a reason");
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
if (to === "blocked") {
|
|
1613
|
+
// Only live work can be blocked; otherwise blocking then unblocking would reopen a finished main task.
|
|
1614
|
+
if (ai ? from !== "claimed" : isFinished(card))
|
|
1615
|
+
throw new TaskBoardError("invalid", `${card.code} is ${from}; it cannot be blocked`, "cannot_block", [card.code, from]);
|
|
1616
|
+
if (ai && !own)
|
|
1617
|
+
throw notOwner();
|
|
1618
|
+
if (!reason)
|
|
1619
|
+
throw new TaskBoardError("invalid", "blocking needs a reason: who has to answer what", "block_reason");
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
if (from === "blocked" && to === "claimed") {
|
|
1623
|
+
if (ai && !own) {
|
|
1624
|
+
if (!isDispatcher(card, actor))
|
|
1625
|
+
throw notOwner();
|
|
1626
|
+
this.checkAnswer(card, opts.comment);
|
|
1627
|
+
}
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (ai) {
|
|
1631
|
+
if (from === "claimed" && to === "review") {
|
|
1632
|
+
if (!own)
|
|
1633
|
+
throw notOwner();
|
|
1634
|
+
if (!this.activeSubs(card.uuid).every((k) => k.status === "done")) {
|
|
1635
|
+
throw new TaskBoardError("invalid", `${card.code} has unfinished sub tasks; it goes to review by itself when they are all done`);
|
|
1636
|
+
}
|
|
1637
|
+
if (!card.result)
|
|
1638
|
+
throw new TaskBoardError("invalid", "resubmitting needs a result: what changed and how it was checked");
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
throw new TaskBoardError("forbidden", `${card.code} has sub tasks; its status follows them — move its sub tasks along`);
|
|
1642
|
+
}
|
|
1643
|
+
if (from === "review" && to === "done") {
|
|
1644
|
+
if (reason)
|
|
1645
|
+
this.entry(card, "approve", actor, reason);
|
|
1646
|
+
this.checkFinish(card, ai, reason);
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
if (from === "review" && to === "claimed") {
|
|
1650
|
+
if (!reason)
|
|
1651
|
+
throw new TaskBoardError("invalid", "sending work back needs a reason", "sendback_reason");
|
|
1652
|
+
this.entry(card, "reject", actor, reason);
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
if ((from === "done" && to === "claimed") || (from === "cancelled" && to === "open"))
|
|
1656
|
+
return;
|
|
1657
|
+
throw new TaskBoardError("invalid", `${card.code} has sub tasks; its status follows them — you can confirm it, send it back, block it or cancel it`, "derived_move", [card.code]);
|
|
1658
|
+
}
|
|
1659
|
+
/** What leaving "in progress" as finished (review or done) needs. */
|
|
1660
|
+
checkFinish(card, ai, reason) {
|
|
1661
|
+
const open = this.children(card.uuid).filter((k) => !isFinished(k));
|
|
1662
|
+
if (open.length)
|
|
1663
|
+
throw new TaskBoardError("invalid", `${card.code} still has unfinished sub tasks: ${open.map((k) => k.code).join(", ")}`, "unfinished_subs", [card.code, open.map((k) => k.code).join(", ")]);
|
|
1664
|
+
if (!ai)
|
|
1665
|
+
return;
|
|
1666
|
+
if (!card.result)
|
|
1667
|
+
throw new TaskBoardError("invalid", "finishing needs a result: which files changed and how it was checked");
|
|
1668
|
+
const unchecked = card.acceptance.map((a, i) => ({ ...a, n: i + 1 })).filter((a) => !a.done);
|
|
1669
|
+
if (unchecked.length && !reason) {
|
|
1670
|
+
throw new TaskBoardError("invalid", `${unchecked.length} acceptance item(s) unchecked (${unchecked.map((a) => a.n).join(", ")}); check them, or say why in the reason`);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
canEdit(card, actor) {
|
|
1674
|
+
return card.assignee === null || card.assignee === actor.name || card.createdBy === actor.name || isDispatcher(card, actor);
|
|
1675
|
+
}
|
|
1676
|
+
/** Its dispatcher answering a blocked card says what was decided: the assignee reads it. */
|
|
1677
|
+
checkAnswer(card, comment) {
|
|
1678
|
+
if (!comment?.trim())
|
|
1679
|
+
throw new TaskBoardError("invalid", `answering ${card.code}'s blocked question takes the decision as a comment`);
|
|
1680
|
+
}
|
|
1681
|
+
/**
|
|
1682
|
+
* An instance works on one card of the board at a time — or as many as its `board_cards_at_once` allows: starting
|
|
1683
|
+
* another (claiming, resuming a blocked one, taking one back from review) at its limit is refused. Blocked cards,
|
|
1684
|
+
* main tasks that follow their sub tasks and cards it opened for itself do not count; the `task` tool's own tasks and
|
|
1685
|
+
* cards it opened for itself are not limited, nor is a person.
|
|
1686
|
+
*/
|
|
1687
|
+
checkOneAtATime(card, actor) {
|
|
1688
|
+
if (openedForItself(card))
|
|
1689
|
+
return;
|
|
1690
|
+
const busy = this.busyWith(card, actor.name);
|
|
1691
|
+
if (!busy.length)
|
|
1692
|
+
return;
|
|
1693
|
+
const list = busy.map((c) => `${c.code} "${c.title.length > 30 ? `${c.title.slice(0, 30)}…` : c.title}"`).join(", ");
|
|
1694
|
+
const limit = this.limitOf(actor.name);
|
|
1695
|
+
throw new TaskBoardError("one_at_a_time", limit === 1
|
|
1696
|
+
? `you already have ${list} in progress. One card at a time: finish it first; if you cannot go on, move it to blocked with a reason, then start ${card.code}`
|
|
1697
|
+
: `you already have ${list} in progress: at most ${limit} cards at a time. Finish one first; if you cannot go on, move one to blocked with a reason, then start ${card.code}`);
|
|
1698
|
+
}
|
|
1699
|
+
/** The cards that keep `name` from starting `card` now: those it has in progress, once they reach its limit. */
|
|
1700
|
+
busyWith(card, name) {
|
|
1701
|
+
const counted = this.inProgress(name).filter((c) => c.uuid !== card.uuid && !openedForItself(c));
|
|
1702
|
+
return counted.length >= this.limitOf(name) ? counted : [];
|
|
1703
|
+
}
|
|
1704
|
+
/** The fleet's `board_cards_at_once` for an instance: a whole number of at least 1, else 1. */
|
|
1705
|
+
limitOf(name) {
|
|
1706
|
+
const n = this.cardLimit(name);
|
|
1707
|
+
return Number.isInteger(n) && n >= 1 ? n : 1;
|
|
1708
|
+
}
|
|
1709
|
+
/** For `mine`: the cards that count toward an instance's limit, when there are more than it allows (a person moved them). */
|
|
1710
|
+
overLimit(name) {
|
|
1711
|
+
const count = this.inProgress(name).filter((c) => !openedForItself(c)).length;
|
|
1712
|
+
const limit = this.limitOf(name);
|
|
1713
|
+
return count > limit ? { count, limit } : null;
|
|
1714
|
+
}
|
|
1715
|
+
/** How many handed-out cards each instance may have in progress (the fleet reads its config; default 1). */
|
|
1716
|
+
setCardLimit(limit) {
|
|
1717
|
+
this.cardLimit = limit;
|
|
1718
|
+
}
|
|
1719
|
+
/** The board cards an instance works on itself right now (main tasks that follow their sub tasks aside). */
|
|
1720
|
+
inProgress(name) {
|
|
1721
|
+
return this.db.prepare("SELECT * FROM tasks WHERE assignee = ? AND status = 'claimed' ORDER BY sort").all(name).map(toCard)
|
|
1722
|
+
.filter((c) => this.isOnBoard(c) && !(c.kind === "main" && this.activeSubs(c.uuid).length));
|
|
1723
|
+
}
|
|
1724
|
+
personOnly(actor, what) {
|
|
1725
|
+
if (!isPerson(actor))
|
|
1726
|
+
throw new TaskBoardError("forbidden", `only a person can ${what}`);
|
|
1727
|
+
}
|
|
1728
|
+
// ── Derived state ───────────────────────────────────────
|
|
1729
|
+
/** Status bookkeeping shared by every move, then what the move sets off. */
|
|
1730
|
+
applyMove(card, actor, to, reason, comment) {
|
|
1731
|
+
const from = card.status;
|
|
1732
|
+
const now = this.nowIso();
|
|
1733
|
+
card.status = to;
|
|
1734
|
+
if (to === "claimed" && !card.startedAt)
|
|
1735
|
+
card.startedAt = now;
|
|
1736
|
+
if (to === "review")
|
|
1737
|
+
card.submittedAt = now;
|
|
1738
|
+
card.doneAt = to === "done" ? now : null;
|
|
1739
|
+
card.blockedReason = to === "blocked" ? reason : "";
|
|
1740
|
+
if (comment?.trim()) {
|
|
1741
|
+
const text = checkText("the comment", comment, CARD_LIMITS.comment);
|
|
1742
|
+
this.entry(card, "comment", actor, text);
|
|
1743
|
+
this.event(actor, card, "commented", { text });
|
|
1744
|
+
}
|
|
1745
|
+
card.version++;
|
|
1746
|
+
card.updatedAt = now;
|
|
1747
|
+
this.surfaceComments(card, actor);
|
|
1748
|
+
this.markSeen(card, actor);
|
|
1749
|
+
this.save(card);
|
|
1750
|
+
this.event(actor, card, "moved", reason ? { from, to, reason } : { from, to });
|
|
1751
|
+
if (to === "cancelled")
|
|
1752
|
+
this.cancelSubs(card, actor);
|
|
1753
|
+
if (card.kind === "main" && from === "cancelled")
|
|
1754
|
+
this.restoreSubs(card, actor);
|
|
1755
|
+
if (card.kind === "main" && (from === "cancelled" || from === "blocked"))
|
|
1756
|
+
this.deriveMain(card, actor);
|
|
1757
|
+
if (card.parent) {
|
|
1758
|
+
const main = this.byUuid(card.parent);
|
|
1759
|
+
if (main)
|
|
1760
|
+
this.deriveMain(main, actor);
|
|
1761
|
+
}
|
|
1762
|
+
if (to === "done")
|
|
1763
|
+
this.noteUnblocked(card);
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* A main task with sub tasks follows them: open until something starts, in progress while they run, and when they
|
|
1767
|
+
* are all done, review (or done, for one that needs no confirmation). Only whoever confirms it (#2434) moves it
|
|
1768
|
+
* from review to done. A blocked or cancelled main task is left alone, and so is one without sub tasks (it is moved by hand).
|
|
1769
|
+
*/
|
|
1770
|
+
/** What a main task's sub tasks make its status (its own, when it is blocked, cancelled or has none). */
|
|
1771
|
+
derivedStatus(main) {
|
|
1772
|
+
if (main.status === "cancelled" || main.status === "blocked")
|
|
1773
|
+
return main.status;
|
|
1774
|
+
const subs = this.activeSubs(main.uuid);
|
|
1775
|
+
if (!subs.length)
|
|
1776
|
+
return main.status;
|
|
1777
|
+
const allDone = subs.every((k) => k.status === "done");
|
|
1778
|
+
const started = main.startedAt !== null || subs.some((k) => k.status !== "open");
|
|
1779
|
+
const finished = requiresReview(main) ? "review" : "done";
|
|
1780
|
+
if (main.status === "done" || main.status === "review")
|
|
1781
|
+
return allDone ? main.status : started ? "claimed" : "open";
|
|
1782
|
+
return allDone ? finished : started ? "claimed" : "open";
|
|
1783
|
+
}
|
|
1784
|
+
deriveMain(main, actor) {
|
|
1785
|
+
const next = this.derivedStatus(main);
|
|
1786
|
+
if (next === main.status)
|
|
1787
|
+
return;
|
|
1788
|
+
const from = main.status;
|
|
1789
|
+
const now = this.nowIso();
|
|
1790
|
+
main.status = next;
|
|
1791
|
+
if (next === "claimed" && !main.startedAt)
|
|
1792
|
+
main.startedAt = now;
|
|
1793
|
+
if (next === "review")
|
|
1794
|
+
main.submittedAt = now;
|
|
1795
|
+
main.doneAt = next === "done" ? now : null;
|
|
1796
|
+
main.version++;
|
|
1797
|
+
main.updatedAt = now;
|
|
1798
|
+
this.save(main);
|
|
1799
|
+
this.event(actor, main, "moved", { from, to: next, auto: true });
|
|
1800
|
+
if (next === "review")
|
|
1801
|
+
this.say("review_waiting", [main.code, main.title], `${main.code} "${main.title}": every sub task is done; it is waiting for ${confirmerOf(main)}'s confirmation`);
|
|
1802
|
+
else if (from === "done" || from === "review")
|
|
1803
|
+
this.say("back_to", [main.code, next], `${main.code} has unfinished sub tasks again; it is back to ${next}`);
|
|
1804
|
+
if (next === "done")
|
|
1805
|
+
this.noteUnblocked(main);
|
|
1806
|
+
}
|
|
1807
|
+
/** Cancelling a main task cancels its unfinished sub tasks (the event remembers where each was). */
|
|
1808
|
+
cancelSubs(card, actor) {
|
|
1809
|
+
for (const sub of this.children(card.uuid)) {
|
|
1810
|
+
if (isFinished(sub))
|
|
1811
|
+
continue;
|
|
1812
|
+
const from = sub.status;
|
|
1813
|
+
sub.status = "cancelled";
|
|
1814
|
+
sub.version++;
|
|
1815
|
+
sub.updatedAt = this.nowIso();
|
|
1816
|
+
this.save(sub);
|
|
1817
|
+
this.event(actor, sub, "moved", { from, to: "cancelled", auto: true, cascade: card.code });
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
/** Restoring a main task brings back the sub tasks its cancellation took down, where they were. */
|
|
1821
|
+
restoreSubs(main, actor) {
|
|
1822
|
+
for (const sub of this.children(main.uuid)) {
|
|
1823
|
+
if (sub.status !== "cancelled")
|
|
1824
|
+
continue;
|
|
1825
|
+
const last = this.db.prepare("SELECT data FROM task_events WHERE task_id = ? AND type = 'moved' ORDER BY id DESC LIMIT 1").get(sub.uuid);
|
|
1826
|
+
const data = last ? JSON.parse(last.data) : {};
|
|
1827
|
+
if (data.cascade !== main.code || !data.from)
|
|
1828
|
+
continue;
|
|
1829
|
+
sub.status = data.from;
|
|
1830
|
+
sub.version++;
|
|
1831
|
+
sub.updatedAt = this.nowIso();
|
|
1832
|
+
this.save(sub);
|
|
1833
|
+
this.event(actor, sub, "moved", { from: "cancelled", to: data.from, auto: true, restored: main.code });
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
/** `card` is done: the open cards that waited for it and wait for nothing else now can start. */
|
|
1837
|
+
noteUnblocked(card) {
|
|
1838
|
+
const rows = this.db.prepare("SELECT * FROM tasks WHERE status = 'open' AND depends_on LIKE ?").all(`%"${card.uuid}"%`);
|
|
1839
|
+
for (const dependent of rows.map(toCard)) {
|
|
1840
|
+
if (this.unmetDeps(dependent).length === 0)
|
|
1841
|
+
this.say("deps_done", [dependent.code], `${dependent.code}'s dependencies are all done; it can start`);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
/** Every card's place again 1, 2, 3… in the same order (reorder() calls it when two neighbours touch). */
|
|
1845
|
+
renumber() {
|
|
1846
|
+
const rows = this.db.prepare("SELECT id FROM tasks ORDER BY sort, rowid").all();
|
|
1847
|
+
const set = this.db.prepare("UPDATE tasks SET sort = ? WHERE id = ?");
|
|
1848
|
+
rows.forEach((r, i) => set.run(i + 1, r.id));
|
|
1849
|
+
}
|
|
1850
|
+
/** Several of the board's writes as one: all of them or none (the `task` tool's update with fields and a status). */
|
|
1851
|
+
atomically(fn) {
|
|
1852
|
+
return this.tx(fn);
|
|
1853
|
+
}
|
|
1854
|
+
/** This write's notes so far, both ways. */
|
|
1855
|
+
spoken() {
|
|
1856
|
+
return { notes: this.notes.map((n) => n.text), said: [...this.notes] };
|
|
1857
|
+
}
|
|
1858
|
+
/** A note of this write: English for agents, its key and values for the dashboard. */
|
|
1859
|
+
say(key, args, text) {
|
|
1860
|
+
this.notes.push({ text, key, args });
|
|
1861
|
+
}
|
|
1862
|
+
/** The last write's notes (English), once: the `task` tool's old actions return them with their answer. */
|
|
1863
|
+
takeNotes() {
|
|
1864
|
+
const notes = this.lastNotes;
|
|
1865
|
+
this.lastNotes = [];
|
|
1866
|
+
return notes;
|
|
1867
|
+
}
|
|
1868
|
+
// ── Wakes ───────────────────────────────────────────────
|
|
1869
|
+
/**
|
|
1870
|
+
* Whom this write wakes, read from its events once it is all written (still inside it): an instance hears only about
|
|
1871
|
+
* what someone else did to a card that is its own — or that it handed out (a blocked question, a confirmation). The
|
|
1872
|
+
* board's own catch-up wakes nobody, except that a card finishing — by hand or by itself — readies the cards that
|
|
1873
|
+
* waited only for it. wakeHolds() then drops what no longer stands.
|
|
1874
|
+
*/
|
|
1875
|
+
settleWakes(events) {
|
|
1876
|
+
const out = new Map();
|
|
1877
|
+
const add = (card, instance, reason, by, text) => {
|
|
1878
|
+
if (!instance || instance === by.name)
|
|
1879
|
+
return;
|
|
1880
|
+
const key = `${instance}\u0000${card.uuid}`;
|
|
1881
|
+
const w = out.get(key) ?? { instance, uuid: card.uuid, code: card.code, title: card.title, parent: card.parent ? this.codeOf(card.parent) : null, reasons: [], by: {}, texts: {} };
|
|
1882
|
+
if (!w.reasons.includes(reason))
|
|
1883
|
+
w.reasons.push(reason);
|
|
1884
|
+
if (text)
|
|
1885
|
+
w.texts[reason] = text;
|
|
1886
|
+
w.by[reason] = by.name;
|
|
1887
|
+
out.set(key, w);
|
|
1888
|
+
};
|
|
1889
|
+
const comments = new Map();
|
|
1890
|
+
for (const e of events)
|
|
1891
|
+
if (e.type === "commented")
|
|
1892
|
+
comments.set(e.uuid, String(e.data.text ?? ""));
|
|
1893
|
+
for (const e of events) {
|
|
1894
|
+
const card = this.byUuid(e.uuid);
|
|
1895
|
+
if (!card)
|
|
1896
|
+
continue;
|
|
1897
|
+
const d = e.data;
|
|
1898
|
+
const dispatcherOf = () => (card.dispatcher?.kind === "instance" && card.dispatcher.name !== card.assignee ? card.dispatcher.name : null);
|
|
1899
|
+
// The `task` tool's own tasks are handed over with a message of their own (delegate_task): no notice besides.
|
|
1900
|
+
if (e.type === "created") {
|
|
1901
|
+
if (this.isOnBoard(card))
|
|
1902
|
+
add(card, d.assignee ?? null, "assigned", e.actor);
|
|
1903
|
+
}
|
|
1904
|
+
else if (e.type === "assigned") {
|
|
1905
|
+
if (this.isOnBoard(card))
|
|
1906
|
+
add(card, d.to ?? null, "assigned", e.actor);
|
|
1907
|
+
}
|
|
1908
|
+
else if (e.type === "commented")
|
|
1909
|
+
add(card, card.assignee, "comment", e.actor, d.text);
|
|
1910
|
+
else if (e.type === "moved") {
|
|
1911
|
+
if (d.to === "done")
|
|
1912
|
+
this.wakeReady(card, e.actor, add);
|
|
1913
|
+
if (d.to === "cancelled") {
|
|
1914
|
+
// Taken down with its main task: why the main task was cancelled.
|
|
1915
|
+
const main = d.cascade ? events.find((x) => x.code === d.cascade && x.type === "moved" && x.data.to === "cancelled") : undefined;
|
|
1916
|
+
const why = d.cascade ? `with its main task ${d.cascade}${main?.data.reason ? `: ${String(main.data.reason)}` : ""}` : d.reason;
|
|
1917
|
+
add(card, card.assignee, "cancelled", e.actor, why);
|
|
1918
|
+
continue;
|
|
1919
|
+
}
|
|
1920
|
+
// An agent's card waits for its instance dispatcher to confirm it (#2434: its own to confirm, nobody to ask). A
|
|
1921
|
+
// person's card has no instance dispatcher, so no notice: the person has it in Needs you.
|
|
1922
|
+
if (d.to === "review" && !isPerson(e.actor) && requiresReview(card))
|
|
1923
|
+
add(card, dispatcherOf(), "to_confirm", e.actor);
|
|
1924
|
+
if (d.auto) {
|
|
1925
|
+
if (d.to === "review" && card.kind === "main" && wrapUpGap(card))
|
|
1926
|
+
add(card, card.assignee, "wrap_up", e.actor);
|
|
1927
|
+
continue;
|
|
1928
|
+
}
|
|
1929
|
+
if (d.from === "open" && d.to === "claimed")
|
|
1930
|
+
add(card, card.assignee, "start", e.actor);
|
|
1931
|
+
else if (d.from === "blocked" && d.to === "claimed")
|
|
1932
|
+
add(card, card.assignee, "answered", e.actor, comments.get(card.uuid));
|
|
1933
|
+
else if (d.from === "review" && (d.to === "claimed" || d.to === "open"))
|
|
1934
|
+
add(card, card.assignee, "sent_back", e.actor, d.reason);
|
|
1935
|
+
else if (d.from === "review" && d.to === "done") {
|
|
1936
|
+
add(card, card.assignee, "approved", e.actor, d.reason);
|
|
1937
|
+
add(card, dispatcherOf(), "approved", e.actor, d.reason);
|
|
1938
|
+
}
|
|
1939
|
+
if (d.to === "blocked" && !isPerson(e.actor))
|
|
1940
|
+
add(card, dispatcherOf(), "decide", e.actor, d.reason);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
return [...out.values()].map((w) => this.wakeHolds(w)).filter((w) => w !== null);
|
|
1944
|
+
}
|
|
1945
|
+
/** A finished card readies the open cards that waited only for it (a sub task also waits for its main task's). */
|
|
1946
|
+
wakeReady(done, by, add) {
|
|
1947
|
+
const rows = this.db.prepare("SELECT * FROM tasks WHERE status = 'open' AND assignee IS NOT NULL AND (depends_on LIKE ? OR parent_id IN (SELECT id FROM tasks WHERE depends_on LIKE ?))")
|
|
1948
|
+
.all(`%"${done.uuid}"%`, `%"${done.uuid}"%`);
|
|
1949
|
+
for (const card of rows.map(toCard))
|
|
1950
|
+
if (!this.unmetDeps(card).length)
|
|
1951
|
+
add(card, card.assignee, "ready", by);
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* What of a wake still stands now (at the end of its write, and again when the fleet sends it): a finished card is
|
|
1955
|
+
* news only as a confirmation; a card that is no longer the instance's is not; a question only while the card is
|
|
1956
|
+
* blocked and the instance still handed it out; a wrap-up only while it waits unfinished; and an assignment or a
|
|
1957
|
+
* comment only once the card can start (its "ready" wakes it then). What asks the instance to act holds only while
|
|
1958
|
+
* it still can (#2303 review): "ready" while the card is open with nothing left to wait for, "start" and "answered"
|
|
1959
|
+
* while it is in progress, "sent back" while it is back with the instance; a comment only while its assignee has not
|
|
1960
|
+
* read it (its own write since showed it, as the `task` tool's answer). null when nothing is left.
|
|
1961
|
+
*/
|
|
1962
|
+
wakeHolds(wake) {
|
|
1963
|
+
const card = this.byUuid(wake.uuid);
|
|
1964
|
+
if (!card)
|
|
1965
|
+
return null;
|
|
1966
|
+
const handedOut = card.dispatcher?.kind === "instance" && card.dispatcher.name === wake.instance;
|
|
1967
|
+
const own = card.assignee === wake.instance;
|
|
1968
|
+
const waiting = this.unmetDeps(card).length > 0;
|
|
1969
|
+
let reasons = wake.reasons.filter((r) => {
|
|
1970
|
+
if (r === "approved")
|
|
1971
|
+
return card.status === "done" && (own || handedOut);
|
|
1972
|
+
if (r === "cancelled")
|
|
1973
|
+
return card.status === "cancelled" && own;
|
|
1974
|
+
if (isFinished(card))
|
|
1975
|
+
return false;
|
|
1976
|
+
if (r === "decide")
|
|
1977
|
+
return handedOut && card.status === "blocked";
|
|
1978
|
+
// #2341 review: and still waiting for the person — its confirmation not switched off during the gather.
|
|
1979
|
+
if (r === "to_confirm")
|
|
1980
|
+
return handedOut && card.status === "review" && requiresReview(card);
|
|
1981
|
+
if (!own)
|
|
1982
|
+
return false;
|
|
1983
|
+
if (r === "wrap_up")
|
|
1984
|
+
return card.status === "review" && wrapUpGap(card) !== null;
|
|
1985
|
+
if (r === "assigned")
|
|
1986
|
+
return !waiting;
|
|
1987
|
+
if (r === "comment")
|
|
1988
|
+
return !waiting && this.newComments(card).length > 0;
|
|
1989
|
+
if (r === "ready")
|
|
1990
|
+
return card.status === "open" && !waiting;
|
|
1991
|
+
if (r === "start" || r === "answered")
|
|
1992
|
+
return card.status === "claimed";
|
|
1993
|
+
if (r === "sent_back")
|
|
1994
|
+
return card.status === "claimed" || card.status === "open";
|
|
1995
|
+
return true;
|
|
1996
|
+
});
|
|
1997
|
+
// One piece of news says it all: the answer is the comment, a send-back outweighs the rest.
|
|
1998
|
+
if (reasons.includes("answered"))
|
|
1999
|
+
reasons = reasons.filter((r) => r !== "comment");
|
|
2000
|
+
if (reasons.includes("sent_back"))
|
|
2001
|
+
reasons = reasons.filter((r) => r !== "comment" && r !== "start");
|
|
2002
|
+
if (reasons.includes("assigned"))
|
|
2003
|
+
reasons = reasons.filter((r) => r !== "start" && r !== "ready");
|
|
2004
|
+
if (reasons.includes("cancelled"))
|
|
2005
|
+
reasons = ["cancelled"];
|
|
2006
|
+
if (!reasons.length)
|
|
2007
|
+
return null;
|
|
2008
|
+
const keep = (o) => Object.fromEntries(Object.entries(o).filter(([r]) => reasons.includes(r)));
|
|
2009
|
+
return { ...wake, title: card.title, reasons, texts: keep(wake.texts), by: keep(wake.by) };
|
|
2010
|
+
}
|
|
2011
|
+
// ── Helpers ─────────────────────────────────────────────
|
|
2012
|
+
/**
|
|
2013
|
+
* One write, all or nothing (BEGIN IMMEDIATE: the CLI and the fleet share this file). Listeners hear about it after
|
|
2014
|
+
* it commits; a nested call joins the outer write.
|
|
2015
|
+
*/
|
|
2016
|
+
tx(fn) {
|
|
2017
|
+
if (this.db.inTransaction)
|
|
2018
|
+
return fn();
|
|
2019
|
+
this.pending = [];
|
|
2020
|
+
this.projectsChanged = false;
|
|
2021
|
+
this.notes = [];
|
|
2022
|
+
try {
|
|
2023
|
+
let wakes = [];
|
|
2024
|
+
const result = this.db.transaction(() => {
|
|
2025
|
+
const r = fn();
|
|
2026
|
+
if (this.pending.length || this.projectsChanged)
|
|
2027
|
+
this.db.prepare("UPDATE task_counters SET next = next + 1 WHERE prefix = 'R'").run();
|
|
2028
|
+
wakes = this.settleWakes(this.pending);
|
|
2029
|
+
return r;
|
|
2030
|
+
}).immediate();
|
|
2031
|
+
this.lastNotes = this.notes.map((n) => n.text);
|
|
2032
|
+
const touched = this.pending;
|
|
2033
|
+
const projects = this.projectsChanged;
|
|
2034
|
+
if (touched.length || projects) {
|
|
2035
|
+
const change = { rev: this.revision(), ids: [...new Set(touched.map((e) => e.code))], actors: [...new Set(touched.map((e) => e.actor.name))], projects, wakes };
|
|
2036
|
+
for (const listener of this.listeners) {
|
|
2037
|
+
try {
|
|
2038
|
+
listener(change);
|
|
2039
|
+
}
|
|
2040
|
+
catch { /* a listener must not break a write that already committed */ }
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
return result;
|
|
2044
|
+
}
|
|
2045
|
+
finally {
|
|
2046
|
+
this.pending = [];
|
|
2047
|
+
this.projectsChanged = false;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
nowIso() {
|
|
2051
|
+
return this.now().toISOString();
|
|
2052
|
+
}
|
|
2053
|
+
nextCode(prefix) {
|
|
2054
|
+
const r = this.db.prepare("UPDATE task_counters SET next = next + 1 WHERE prefix = ? RETURNING next - 1 AS n").get(prefix);
|
|
2055
|
+
return `${prefix}-${r.n}`;
|
|
2056
|
+
}
|
|
2057
|
+
nextSort() {
|
|
2058
|
+
return this.db.prepare("SELECT COALESCE(MAX(sort), 0) + 1 AS s FROM tasks").get().s;
|
|
2059
|
+
}
|
|
2060
|
+
/** A card number, uuid, or "#n" for the n-th card of the same batch → that card's uuid. */
|
|
2061
|
+
resolveRef(ref, batch) {
|
|
2062
|
+
const m = /^\s*#(\d+)\s*$/.exec(ref);
|
|
2063
|
+
if (!m)
|
|
2064
|
+
return this.getCard(ref).uuid;
|
|
2065
|
+
const n = Number(m[1]);
|
|
2066
|
+
if (n < 1 || n > batch.length)
|
|
2067
|
+
throw new TaskBoardError("invalid", `"#${n}" can only point at an earlier card of the same batch`);
|
|
2068
|
+
return batch[n - 1].uuid;
|
|
2069
|
+
}
|
|
2070
|
+
/** A sub task's parent: a main task that is not cancelled. */
|
|
2071
|
+
resolveParent(uuid, actor) {
|
|
2072
|
+
const parent = this.byUuid(uuid);
|
|
2073
|
+
if (parent.kind !== "main")
|
|
2074
|
+
throw new TaskBoardError("invalid", `${parent.code} is a sub task; sub tasks are not split further — add more sub tasks to the main task`, "parent_is_sub", [parent.code]);
|
|
2075
|
+
if (parent.status === "cancelled")
|
|
2076
|
+
throw new TaskBoardError("invalid", `${parent.code} is cancelled; it takes no new sub tasks`, "parent_cancelled", [parent.code]);
|
|
2077
|
+
// A new sub task reopens a finished main task, and only a person reopens finished work.
|
|
2078
|
+
if (parent.status === "done" && !isPerson(actor))
|
|
2079
|
+
throw new TaskBoardError("forbidden", `${parent.code} is done; ask the person to reopen it before adding to it`);
|
|
2080
|
+
return parent;
|
|
2081
|
+
}
|
|
2082
|
+
/** Valid, unique dependency uuids. Cycles are checked once the change is written (assertNoCycle). */
|
|
2083
|
+
checkDeps(card, refs) {
|
|
2084
|
+
const out = [];
|
|
2085
|
+
for (const ref of refs) {
|
|
2086
|
+
const dep = this.byUuid(ref) ?? this.getCard(ref);
|
|
2087
|
+
if (dep.uuid === card.uuid)
|
|
2088
|
+
throw new TaskBoardError("invalid", "a card cannot depend on itself", "dep_self");
|
|
2089
|
+
if (card.kind === "sub" && dep.uuid === card.parent)
|
|
2090
|
+
throw new TaskBoardError("invalid", `a sub task cannot depend on its own main task ${dep.code} (the main task already waits for it)`, "dep_own_main", [dep.code]);
|
|
2091
|
+
if (card.kind === "main" && card.uuid && dep.parent === card.uuid)
|
|
2092
|
+
throw new TaskBoardError("invalid", `a main task cannot depend on its own sub task ${dep.code} (it already waits for it)`, "dep_own_sub", [dep.code]);
|
|
2093
|
+
if (!out.includes(dep.uuid))
|
|
2094
|
+
out.push(dep.uuid);
|
|
2095
|
+
}
|
|
2096
|
+
return out;
|
|
2097
|
+
}
|
|
2098
|
+
/** What a card waits on: its dependencies; a sub task also its main task's; a main task its sub tasks. */
|
|
2099
|
+
waitsOn(uuid) {
|
|
2100
|
+
const card = this.byUuid(uuid);
|
|
2101
|
+
if (!card)
|
|
2102
|
+
return [];
|
|
2103
|
+
const out = [...card.deps];
|
|
2104
|
+
if (card.kind === "sub" && card.parent)
|
|
2105
|
+
out.push(...(this.byUuid(card.parent)?.deps ?? []));
|
|
2106
|
+
if (card.kind === "main")
|
|
2107
|
+
out.push(...this.activeSubs(card.uuid).map((k) => k.uuid));
|
|
2108
|
+
return out;
|
|
2109
|
+
}
|
|
2110
|
+
/** Refuse a dependency loop reachable from `start`, counting the implicit main/sub waits. */
|
|
2111
|
+
assertNoCycle(start) {
|
|
2112
|
+
const state = new Map();
|
|
2113
|
+
const visit = (uuid, path) => {
|
|
2114
|
+
if (state.get(uuid) === "done")
|
|
2115
|
+
return;
|
|
2116
|
+
if (state.get(uuid) === "visiting") {
|
|
2117
|
+
const loop = [...path.slice(path.indexOf(uuid)), uuid].map((u) => this.codeOf(u)).join(" → ");
|
|
2118
|
+
throw new TaskBoardError("invalid", `these dependencies would wait on each other forever: ${loop}`, "dep_cycle", [loop]);
|
|
2119
|
+
}
|
|
2120
|
+
state.set(uuid, "visiting");
|
|
2121
|
+
for (const next of this.waitsOn(uuid))
|
|
2122
|
+
visit(next, [...path, uuid]);
|
|
2123
|
+
state.set(uuid, "done");
|
|
2124
|
+
};
|
|
2125
|
+
visit(start, []);
|
|
2126
|
+
}
|
|
2127
|
+
checkVersion(card, version) {
|
|
2128
|
+
if (version === undefined || version === card.version)
|
|
2129
|
+
return;
|
|
2130
|
+
const last = this.db.prepare("SELECT actor FROM task_events WHERE task_id = ? ORDER BY id DESC LIMIT 1").get(card.uuid);
|
|
2131
|
+
throw new TaskBoardError("conflict", `${card.code} changed meanwhile (you have v${version}, it is v${card.version}, last changed by ${last?.actor ?? "someone else"}); read it again first`, "conflict", [card.code, last?.actor ?? "?"]);
|
|
2132
|
+
}
|
|
2133
|
+
assign(card, assignee, actor) {
|
|
2134
|
+
const from = card.assignee;
|
|
2135
|
+
card.assignee = assignee;
|
|
2136
|
+
card.seenEntryId = 0;
|
|
2137
|
+
this.event(actor, card, "assigned", { from, to: assignee });
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Any write by the assignee counts as having seen the comments so far, so the unseen ones go in its reply first;
|
|
2141
|
+
* otherwise a comment posted mid-task would be acknowledged without being read.
|
|
2142
|
+
*/
|
|
2143
|
+
surfaceComments(card, actor) {
|
|
2144
|
+
if (card.assignee !== actor.name)
|
|
2145
|
+
return;
|
|
2146
|
+
for (const e of this.newComments(card)) {
|
|
2147
|
+
const chars = [...e.text];
|
|
2148
|
+
const text = chars.length > 500 ? `${chars.slice(0, 500).join("")}… (read the whole card for the rest)` : e.text;
|
|
2149
|
+
this.say(e.kind === "reject" ? "sent_back" : "new_comment", [e.author, text], `${e.kind === "reject" ? "sent back" : "new comment"} (${e.author}): ${text}`);
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
markSeen(card, actor) {
|
|
2153
|
+
if (card.assignee !== actor.name)
|
|
2154
|
+
return;
|
|
2155
|
+
card.seenEntryId = this.db.prepare("SELECT COALESCE(MAX(id), 0) AS m FROM task_entries WHERE task_id = ?").get(card.uuid).m;
|
|
2156
|
+
}
|
|
2157
|
+
entry(card, kind, actor, text) {
|
|
2158
|
+
this.db.prepare("INSERT INTO task_entries (task_id, kind, author, author_kind, text, at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
2159
|
+
.run(card.uuid, kind, actor.name, actor.kind, text, this.nowIso());
|
|
2160
|
+
}
|
|
2161
|
+
event(actor, card, type, data) {
|
|
2162
|
+
this.db.prepare("INSERT INTO task_events (at, actor, actor_kind, task_id, type, data) VALUES (?, ?, ?, ?, ?, ?)")
|
|
2163
|
+
.run(this.nowIso(), actor.name, actor.kind, card.uuid, type, JSON.stringify(data));
|
|
2164
|
+
this.pending.push({ code: card.code, uuid: card.uuid, actor, type, data });
|
|
2165
|
+
}
|
|
2166
|
+
insert(card) {
|
|
2167
|
+
this.db.prepare(`INSERT INTO tasks (id, code, kind, project_id, parent_id, title, description, status, priority, assignee, dispatcher, dispatcher_kind, created_by, depends_on, result,
|
|
2168
|
+
scope, acceptance, context, state, blocked_reason, size, needs_review, version, sort, created_at, updated_at, started_at, submitted_at, done_at, seen_entry_id, on_board)
|
|
2169
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(card.uuid, card.code, card.kind, card.projectId, card.parent, card.title, card.goal === "" ? null : card.goal, card.status, card.priority, card.assignee, card.dispatcher?.name ?? null, card.dispatcher?.kind ?? null, card.createdBy, card.deps.length ? JSON.stringify(card.deps) : null, card.result === "" ? null : card.result, card.scope, JSON.stringify(card.acceptance), card.context, card.state, card.blockedReason, card.size, card.needsReview ? 1 : 0, card.version, card.sort, card.createdAt, card.updatedAt, card.startedAt, card.submittedAt, card.doneAt, card.seenEntryId, card.onBoard ? 1 : 0);
|
|
2170
|
+
}
|
|
2171
|
+
/** Empty text is stored as NULL in the columns the `task` tool has always had (description, result, depends_on). */
|
|
2172
|
+
save(card) {
|
|
2173
|
+
this.db.prepare(`UPDATE tasks SET project_id = ?, title = ?, description = ?, status = ?, priority = ?, assignee = ?, dispatcher = ?, dispatcher_kind = ?,
|
|
2174
|
+
depends_on = ?, result = ?, scope = ?, acceptance = ?, context = ?, state = ?, blocked_reason = ?, size = ?, needs_review = ?, version = ?,
|
|
2175
|
+
sort = ?, updated_at = ?, started_at = ?, submitted_at = ?, done_at = ?, seen_entry_id = ?
|
|
2176
|
+
WHERE id = ?`).run(card.projectId, card.title, card.goal === "" ? null : card.goal, card.status, card.priority, card.assignee, card.dispatcher?.name ?? null, card.dispatcher?.kind ?? null, card.deps.length ? JSON.stringify(card.deps) : null, card.result === "" ? null : card.result, card.scope, JSON.stringify(card.acceptance), card.context, card.state, card.blockedReason, card.size, card.needsReview ? 1 : 0, card.version, card.sort, card.updatedAt, card.startedAt, card.submittedAt, card.doneAt, card.seenEntryId, card.uuid);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
//# sourceMappingURL=task-board.js.map
|