@staix/agent-hub 0.12.29 → 0.12.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +92 -218
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +3 -2
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +7 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +14 -0
- package/docs/agent-notes/tests.md +6 -3
- package/docs/memory.md +70 -152
- package/docs/operations.md +317 -25
- package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
- package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
- package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
- package/docs/plans/amem-cases.json +482 -0
- package/docs/quickstart.md +24 -2
- package/docs/security.md +42 -2
- package/docs/smoke.md +11 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +142 -7
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
- package/docs/verification/2026-10-17-0.12.30.md +70 -0
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -0
- package/docs/verified.json +13 -13
- package/package.json +5 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +129 -62
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +5 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +145 -29
- package/src/cli/console.ts +40 -10
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +4 -3
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.js +2 -2
- package/src/cli/main.ts +43 -14
- package/src/cli/memory.ts +87 -187
- package/src/cli/output.ts +19 -2
- package/src/cli/recovery-package.ts +59 -10
- package/src/cli/upgrade-interactive.ts +2 -1
- package/src/cli/upgrade-restore-state.ts +133 -0
- package/src/cli/upgrade-runtime.ts +204 -63
- package/src/cli/upgrade.ts +57 -24
- package/src/hub/board.ts +83 -24
- package/src/hub/bus.ts +108 -38
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/control-client.ts +5 -1
- package/src/hub/daemon.ts +529 -89
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +31 -1
- package/src/hub/git.ts +2 -38
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +14 -2
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-candidates.ts +103 -0
- package/src/hub/memory-fail.ts +25 -0
- package/src/hub/memory-observations.ts +47 -0
- package/src/hub/memory-plan-guard.ts +33 -0
- package/src/hub/memory-policy.ts +42 -0
- package/src/hub/memory-profile-check.ts +170 -0
- package/src/hub/memory-profile.ts +192 -0
- package/src/hub/memory-staleness.ts +55 -0
- package/src/hub/path-policy.ts +8 -0
- package/src/hub/peer-catalog.ts +3 -0
- package/src/hub/project.ts +4 -37
- package/src/hub/read-tools.ts +127 -0
- package/src/hub/routing.ts +35 -16
- package/src/hub/settings.ts +2 -1
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +21 -6
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +370 -71
- package/src/local/deny.ts +4 -17
- package/src/memory/brief.ts +8 -5
- package/src/memory/capture.ts +3 -1
- package/src/memory/recall.ts +50 -110
- package/src/pi/process-signature.ts +2 -42
- package/src/ui/index.html +12 -9
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
- package/src/memory/import.ts +0 -90
- package/src/memory/store.ts +0 -805
- package/src/memory/sync.ts +0 -145
package/src/cli/upgrade.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
|
|
1
2
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
|
|
2
3
|
import { Database } from "bun:sqlite";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -53,6 +54,14 @@ export interface ProjectProgress {
|
|
|
53
54
|
phase: "pending" | "prepared" | "stopped" | "started" | "peers-restored" | "verified";
|
|
54
55
|
instanceId?: string;
|
|
55
56
|
terminals: Record<string, unknown>;
|
|
57
|
+
/** #414: last bounded native-attachment timeout, metadata only; never terminal output or task text. */
|
|
58
|
+
attachmentTimeouts?: Record<string, {
|
|
59
|
+
at: number;
|
|
60
|
+
terminal: { handle: string; incarnationId: string; state: "unknown" | "idle" | "busy" | "exited"; connected?: boolean; orphaned?: boolean };
|
|
61
|
+
hub: { state: string; instanceId?: string; peerState?: string; sessionId?: string };
|
|
62
|
+
/** Client-side attempts are not recorded by the hub; an offline peer is not evidence of an attempt. */
|
|
63
|
+
lastHubConnectAttempt: "unknown";
|
|
64
|
+
}>;
|
|
56
65
|
releaseRequested?: boolean;
|
|
57
66
|
/** #215: peers the operator moved to a fresh session, with the session or thread id whose continuity is lost. */
|
|
58
67
|
fresh?: Record<string, { lost: string; reason: string; at: number }>;
|
|
@@ -79,6 +88,8 @@ export interface RecoveryOperation {
|
|
|
79
88
|
sourceRoot: string;
|
|
80
89
|
targetRoot?: string;
|
|
81
90
|
targetDigest?: string;
|
|
91
|
+
/** Verified capability evidence bound to targetRoot/targetDigest; absence is legacy unknown, not an empty list. */
|
|
92
|
+
targetFeatures?: readonly string[];
|
|
82
93
|
projects: ProjectProgress[];
|
|
83
94
|
pluginInstalled?: boolean;
|
|
84
95
|
globalInstalled?: boolean;
|
|
@@ -119,7 +130,7 @@ export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
|
|
|
119
130
|
|
|
120
131
|
/**
|
|
121
132
|
* #225: record the restart of a target that stopped after it started, before the new one starts: the dead instance and
|
|
122
|
-
* the time stay for status and audit. Each done `restored:<peer>` binding of the dead instance is retired
|
|
133
|
+
* the time stay for status and audit. Each done or waiting `restored:<peer>` binding of the dead instance is retired
|
|
123
134
|
* (`retired:<peer>`): restore closes its terminal (or finds it absent) before that peer is relaunched, and never
|
|
124
135
|
* revalidates it against the new instance. `closed:<peer>`, `commitSent`, `fresh`, waivers and the install flags stay.
|
|
125
136
|
* The project goes back to `stopped`, whose path starts the target from this operation's snapshot.
|
|
@@ -127,9 +138,11 @@ export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
|
|
|
127
138
|
export function recordRestart(progress: ProjectProgress, at: number): void {
|
|
128
139
|
(progress.restarts ??= []).push({ instanceId: progress.instanceId ?? "unknown", at });
|
|
129
140
|
for (const [key, value] of Object.entries(progress.terminals)) {
|
|
130
|
-
if (!key.startsWith("restored:")
|
|
141
|
+
if (!key.startsWith("restored:")) continue;
|
|
142
|
+
const receipt = decodeRestoreReceipt(value);
|
|
143
|
+
if (receipt?.state !== "done" && receipt?.state !== "waiting") continue;
|
|
131
144
|
const peer = key.slice("restored:".length);
|
|
132
|
-
progress.terminals[`retired:${peer}`] =
|
|
145
|
+
progress.terminals[`retired:${peer}`] = receipt.binding;
|
|
133
146
|
delete progress.terminals[`closedRetired:${peer}`]; // a newer binding to close: its own receipt starts over
|
|
134
147
|
delete progress.terminals[key];
|
|
135
148
|
}
|
|
@@ -272,7 +285,7 @@ export async function liveProjects(op: RecoveryOperation, inspect: (project: Pro
|
|
|
272
285
|
*/
|
|
273
286
|
function failedPeers(op: RecoveryOperation, progress: ProjectProgress, live: Record<string, Inspection | undefined> = {}): string[] {
|
|
274
287
|
const planned = op.plan.projects.find((p) => p.project.id === progress.id);
|
|
275
|
-
return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && value
|
|
288
|
+
return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && canChooseFresh(value)).map(([key]) => key.slice("restored:".length))
|
|
276
289
|
.filter((peer) => peer !== "pi" && !progress.fresh?.[peer] && !planned?.freshStart?.includes(peer) && !plannedAttached(op, progress, peer, live));
|
|
277
290
|
}
|
|
278
291
|
|
|
@@ -333,17 +346,18 @@ function abandoned(op: RecoveryOperation): boolean {
|
|
|
333
346
|
|
|
334
347
|
function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
335
348
|
if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
|
|
349
|
+
if (op.targetFeatures !== undefined && (!Array.isArray(op.targetFeatures) || !op.targetFeatures.every(feature => typeof feature === "string" && feature.length > 0) || !op.targetRoot || !op.targetDigest)) throw new Error("invalid target features");
|
|
336
350
|
const { fingerprint, ...reviewed } = op.plan;
|
|
337
351
|
if (fingerprint !== planFingerprint(reviewed) || op.projects.length !== op.plan.projects.length ||
|
|
338
352
|
op.projects.some((p, i) => p.id !== op.plan.projects[i]?.project.id)) throw new Error("reviewed operation plan or project scope changed");
|
|
339
353
|
}
|
|
340
354
|
|
|
341
355
|
export interface RecoveryDriver {
|
|
342
|
-
stage(op: RecoveryOperation): Promise<{ root: string; digest: string }>;
|
|
343
|
-
inspect(project: Project): Promise<Inspection>;
|
|
344
|
-
prepare(project: Project, operation: string, instance: string): Promise<void>;
|
|
356
|
+
stage(op: RecoveryOperation): Promise<{ root: string; digest: string; targetFeatures?: readonly string[] }>;
|
|
357
|
+
inspect(project: Project, targetFeatures?: readonly string[]): Promise<Inspection>;
|
|
358
|
+
prepare(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
|
|
345
359
|
abort(project: Project, operation: string, instance: string): Promise<void>;
|
|
346
|
-
commit(project: Project, operation: string, instance: string): Promise<void>;
|
|
360
|
+
commit(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
|
|
347
361
|
closeTerminals(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, save: () => void): Promise<void>;
|
|
348
362
|
start(project: Project, op: RecoveryOperation): Promise<void>;
|
|
349
363
|
restore(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, group: "native" | "claude", save: () => void): Promise<void>;
|
|
@@ -375,6 +389,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
375
389
|
return op;
|
|
376
390
|
}
|
|
377
391
|
} catch (error) { releaseRunner(); throw error; }
|
|
392
|
+
let targetFeatures: readonly string[] = [];
|
|
378
393
|
const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
|
|
379
394
|
const step = (value: string) => { op.step = value; save(); };
|
|
380
395
|
// #225: one restart per project per resume, so a target that keeps dying never spins unattended.
|
|
@@ -414,7 +429,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
414
429
|
// Prepare, or after an expired lease re-prepare, the planned source and wait until it is quiet.
|
|
415
430
|
const prepareSource = async (planned: PlannedProject, progress: ProjectProgress, again = false) => {
|
|
416
431
|
const project = planned.project, instance = planned.source.instanceId!;
|
|
417
|
-
await driver.prepare(project, id, instance);
|
|
432
|
+
await driver.prepare(project, id, instance, targetFeatures);
|
|
418
433
|
const deadline = driver.now() + idleTimeoutMs;
|
|
419
434
|
for (;;) {
|
|
420
435
|
const live = await driver.inspect(project);
|
|
@@ -444,13 +459,16 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
444
459
|
step("stage");
|
|
445
460
|
const target = await driver.stage(op);
|
|
446
461
|
if (op.targetDigest && (target.root !== op.targetRoot || target.digest !== op.targetDigest)) throw new FinalRefusal("staged target changed since operation started");
|
|
447
|
-
|
|
462
|
+
targetFeatures = target.targetFeatures ?? op.targetFeatures ?? [];
|
|
463
|
+
op.targetRoot = target.root; op.targetDigest = target.digest;
|
|
464
|
+
if (target.targetFeatures !== undefined) op.targetFeatures = [...target.targetFeatures];
|
|
465
|
+
save();
|
|
448
466
|
|
|
449
|
-
//
|
|
467
|
+
// Pending sources receive the verified target features before holds or terminal/runtime effects.
|
|
450
468
|
for (let i = 0; i < op.projects.length; i++) {
|
|
451
469
|
const progress = op.projects[i]!, planned = op.plan.projects[i]!;
|
|
452
470
|
if (progress.phase !== "pending") continue;
|
|
453
|
-
const live = await driver.inspect(planned.project);
|
|
471
|
+
const live = await driver.inspect(planned.project, targetFeatures);
|
|
454
472
|
if (!["running", "stopped", "missing"].includes(live.state)) throw new Error(`${planned.project.id}: the source reads as ${live.state}; wait until it answers`);
|
|
455
473
|
if (live.state !== "running" || live.instanceId !== planned.source.instanceId || live.version !== planned.source.version) {
|
|
456
474
|
throw new Error(`${planned.project.id}: source runtime changed`);
|
|
@@ -478,7 +496,14 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
478
496
|
}
|
|
479
497
|
if (progress.phase === "prepared") {
|
|
480
498
|
step(`commit:${project.id}`);
|
|
481
|
-
let live
|
|
499
|
+
let live: Inspection;
|
|
500
|
+
try { live = await driver.inspect(project); }
|
|
501
|
+
catch (error) {
|
|
502
|
+
if (!progress.commitSent || !isDatabaseBusy(error)) throw error;
|
|
503
|
+
live = { state: "unavailable", peers: [], blockers: ["database_busy"] };
|
|
504
|
+
}
|
|
505
|
+
const resumedShutdown = progress.commitSent && live.state === "unavailable";
|
|
506
|
+
let awaitShutdown = resumedShutdown;
|
|
482
507
|
if (live.state === "running" && live.instanceId === planned.source.instanceId) {
|
|
483
508
|
if (live.recovery?.operationId !== id || !live.recovery.ready) {
|
|
484
509
|
const other = live.recovery?.operationId;
|
|
@@ -495,8 +520,22 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
495
520
|
await driver.closeTerminals(planned, progress, op, save);
|
|
496
521
|
// Durable, unlike `step`: from here the source may have committed, which status and abort must not forget.
|
|
497
522
|
progress.commitSent = true; save();
|
|
498
|
-
await driver.commit(project, id, planned.source.instanceId
|
|
523
|
+
await driver.commit(project, id, planned.source.instanceId!, targetFeatures);
|
|
524
|
+
awaitShutdown = true;
|
|
525
|
+
} else if (live.state === "running") {
|
|
526
|
+
throw new Error(`${project.id}: the source daemon was replaced by instance ${live.instanceId ?? "unknown"}; refusing to prepare, close or stop it (ending this operation leaves that daemon running)`);
|
|
527
|
+
} else if (live.state === "stopped" && !progress.commitSent && (!op.sourceRoot || coordinatorCurrent(op.sourceRoot))) {
|
|
528
|
+
// #215: this coordinator records a commit request before sending it, so a stopped source with none crashed while
|
|
529
|
+
// prepared: no snapshot was committed and there is nothing to start from. The phase stays `prepared`.
|
|
530
|
+
throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
|
|
531
|
+
}
|
|
532
|
+
if (awaitShutdown) {
|
|
499
533
|
const deadline = driver.now() + 30_000;
|
|
534
|
+
// An already-sent commit can be between its final reply and the source closing its control server.
|
|
535
|
+
if (resumedShutdown) {
|
|
536
|
+
if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
|
|
537
|
+
await driver.sleep(100);
|
|
538
|
+
}
|
|
500
539
|
do {
|
|
501
540
|
try { live = await driver.inspect(project); }
|
|
502
541
|
catch (error) {
|
|
@@ -509,12 +548,6 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
509
548
|
if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
|
|
510
549
|
await driver.sleep(100);
|
|
511
550
|
} while (driver.now() < deadline);
|
|
512
|
-
} else if (live.state === "running") {
|
|
513
|
-
throw new Error(`${project.id}: the source daemon was replaced by instance ${live.instanceId ?? "unknown"}; refusing to prepare, close or stop it (ending this operation leaves that daemon running)`);
|
|
514
|
-
} else if (live.state === "stopped" && !progress.commitSent && (!op.sourceRoot || coordinatorCurrent(op.sourceRoot))) {
|
|
515
|
-
// #215: this coordinator records a commit request before sending it, so a stopped source with none crashed while
|
|
516
|
-
// prepared: no snapshot was committed and there is nothing to start from. The phase stays `prepared`.
|
|
517
|
-
throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
|
|
518
551
|
}
|
|
519
552
|
if (live.state === "missing") throw new Error(`${project.id}: the project directory is missing`);
|
|
520
553
|
if (live.state !== "stopped") throw new Error(`${project.id}: old shutdown is not verified (the source reads as ${live.state}); not starting a second daemon; check ${recoveryCommand(op, "status")} once it answers`);
|
|
@@ -576,12 +609,12 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
576
609
|
|
|
577
610
|
/** Receipts and runner state only (#215): ids, phases and effects, never task or message text. */
|
|
578
611
|
export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unknown", live: Record<string, Inspection | undefined> = {}) {
|
|
579
|
-
const effect = (value: unknown) => value === "pending" || value === "failed" ? value : "done";
|
|
612
|
+
const effect = (key: string, value: unknown) => key.startsWith("restored:") ? restoreReceiptStatus(value) : value === "pending" || value === "failed" ? value : "done";
|
|
580
613
|
const next = nextActions(op, runnerPid, live);
|
|
581
614
|
return { id: op.id, phase: op.phase, step: op.step, version: op.plan.version, updatedAt: op.updatedAt,
|
|
582
615
|
runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
|
|
583
616
|
...(op.phase === "running" && !runnerPid ? { stale: "the receipt says running but no runner holds the operation: its last runner stopped mid-step" } : {}),
|
|
584
|
-
projects: op.projects.map((p) => ({ id: p.id, phase: p.phase, ...(Object.keys(p.terminals).length ? { effects: Object.fromEntries(Object.entries(p.terminals).map(([key, value]) => [key, effect(value)])) } : {}),
|
|
617
|
+
projects: op.projects.map((p) => ({ id: p.id, phase: p.phase, ...(Object.keys(p.terminals).length ? { effects: Object.fromEntries(Object.entries(p.terminals).map(([key, value]) => [key, effect(key, value)])) } : {}),
|
|
585
618
|
...(p.fresh ? { lostContinuity: Object.fromEntries(Object.entries(p.fresh).map(([peer, f]) => [peer, f.lost])) } : {}),
|
|
586
619
|
...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
|
|
587
620
|
pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
|
|
@@ -661,10 +694,10 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
661
694
|
const peer = choice.fresh;
|
|
662
695
|
// Per project: a planned fresh start has nothing to lose, so only the other failed projects take the choice.
|
|
663
696
|
const failed = op.projects.filter((p) => failedPeers(op, p, live).includes(peer));
|
|
664
|
-
if (!failed.length && op.projects.some((p) => p.terminals[`restored:${peer}`]
|
|
697
|
+
if (!failed.length && op.projects.some((p) => canChooseFresh(p.terminals[`restored:${peer}`]) && op.plan.projects.find((planned) => planned.project.id === p.id)?.freshStart?.includes(peer))) {
|
|
665
698
|
throw new Error(`${peer}: its plan already restarts it as a new session (no rollout and no turn, nothing to lose), so there is no conversation to record as lost; ${next}`);
|
|
666
699
|
}
|
|
667
|
-
if (!failed.length && op.projects.some((p) => p.terminals[`restored:${peer}`]
|
|
700
|
+
if (!failed.length && op.projects.some((p) => canChooseFresh(p.terminals[`restored:${peer}`]) && plannedAttached(op, p, peer, live))) {
|
|
668
701
|
throw new Error(`${peer}: its planned session is attached again, so nothing was lost; resume records it as restored; ${next}`);
|
|
669
702
|
}
|
|
670
703
|
if (!failed.length) throw new Error(`${peer}: no failed restoration of it is open to a fresh session (none failed, or one is already chosen); --fresh-session applies only then (${recoveryCommand(op, "status")})`);
|
package/src/hub/board.ts
CHANGED
|
@@ -8,7 +8,14 @@ import type { SdlcReceipt } from "./sdlc.ts";
|
|
|
8
8
|
|
|
9
9
|
export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
|
|
10
10
|
export type TaskClass = (typeof CLASSES)[number];
|
|
11
|
-
export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "approved" | "changes_requested";
|
|
11
|
+
export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "approved" | "changes_requested" | "cancelled";
|
|
12
|
+
|
|
13
|
+
export const isClosedTaskState = (state: string): boolean => state === "approved" || state === "cancelled";
|
|
14
|
+
export interface TaskOffer { envelopeId: string; owner: PeerId; assignmentFlowRevision: number; pullOnly?: boolean }
|
|
15
|
+
export interface TaskCancellation { reason: string; supersededBy?: string; flowRevision: number; withheld?: true }
|
|
16
|
+
export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable"] as const;
|
|
17
|
+
export type TaskStallCode = (typeof TASK_STALL_CODES)[number];
|
|
18
|
+
export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency"; peer?: string; dependency?: number; code: TaskStallCode; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
|
|
12
19
|
|
|
13
20
|
export interface TaskRefs {
|
|
14
21
|
repo?: string;
|
|
@@ -31,6 +38,7 @@ export interface HistoryEntry {
|
|
|
31
38
|
by: PeerId;
|
|
32
39
|
event: string; // proposed | assigned | accepted | declined | done | approved | changes_requested | escalated | reassigned
|
|
33
40
|
note?: string;
|
|
41
|
+
supersededBy?: string;
|
|
34
42
|
/** The owner an event that set it left the task with (#67); absent in rows written before 0.11. */
|
|
35
43
|
owner?: PeerId | null;
|
|
36
44
|
/** On a decline or an escalation: the owner it was declined for or taken from (#207); later reroutes leave it out. */
|
|
@@ -46,6 +54,8 @@ export interface HistoryEntry {
|
|
|
46
54
|
planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
|
|
47
55
|
}
|
|
48
56
|
export interface Task extends PlanningFields {
|
|
57
|
+
offer?: TaskOffer;
|
|
58
|
+
cancelled?: TaskCancellation;
|
|
49
59
|
external?: GitHubSource;
|
|
50
60
|
planningRevision?: number;
|
|
51
61
|
id: number;
|
|
@@ -91,12 +101,13 @@ export function planningUndoEntry(task: Pick<Task, "history">): HistoryEntry | u
|
|
|
91
101
|
|
|
92
102
|
/** The moves the state machine allows. `accept` and `done` are transitions, not states: they carried no behaviour of their own. */
|
|
93
103
|
const MOVES: Record<TaskState, TaskState[]> = {
|
|
94
|
-
backlog: ["proposed"],
|
|
95
|
-
proposed: ["in_progress", "backlog"],
|
|
96
|
-
in_progress: ["in_review", "approved", "proposed"], // approved: class without a reviewer; proposed: owner declined or was taken off
|
|
97
|
-
in_review: ["approved", "changes_requested"],
|
|
98
|
-
changes_requested: ["in_progress", "proposed"], // proposed: declined redo waits for reassignment
|
|
104
|
+
backlog: ["proposed", "cancelled"],
|
|
105
|
+
proposed: ["in_progress", "backlog", "cancelled"],
|
|
106
|
+
in_progress: ["in_review", "approved", "proposed", "cancelled"], // approved: class without a reviewer; proposed: owner declined or was taken off
|
|
107
|
+
in_review: ["approved", "changes_requested", "cancelled"],
|
|
108
|
+
changes_requested: ["in_progress", "proposed", "cancelled"], // proposed: declined redo waits for reassignment
|
|
99
109
|
approved: [],
|
|
110
|
+
cancelled: [],
|
|
100
111
|
};
|
|
101
112
|
|
|
102
113
|
const JSON_COLS = ["refs", "plan", "deps", "signals", "history", "sdlc"] as const;
|
|
@@ -123,6 +134,19 @@ export class Board {
|
|
|
123
134
|
return result;
|
|
124
135
|
}
|
|
125
136
|
|
|
137
|
+
/** Keep the generation and business write atomic without changing Bun's reported row count. */
|
|
138
|
+
private taskWrite<T extends { changes: number }>(write: () => T): T {
|
|
139
|
+
return this.db.transaction(() => {
|
|
140
|
+
const result = { ...write() };
|
|
141
|
+
if (result.changes) this.db.run("UPDATE board_status_revision SET revision=revision+1 WHERE id=1");
|
|
142
|
+
return result;
|
|
143
|
+
})();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
statusRevision(): number {
|
|
147
|
+
return (this.db.query("SELECT revision FROM board_status_revision WHERE id=1").get() as { revision: number }).revision;
|
|
148
|
+
}
|
|
149
|
+
|
|
126
150
|
constructor(path: string) {
|
|
127
151
|
this.db = new Database(path, { create: true });
|
|
128
152
|
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
@@ -145,6 +169,11 @@ export class Board {
|
|
|
145
169
|
if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN ${col} TEXT NOT NULL DEFAULT '${empty}'`);
|
|
146
170
|
}
|
|
147
171
|
// And from before issue #207: existing rows reserve nobody.
|
|
172
|
+
for (const col of ["offer", "cancelled"]) if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN ${col} TEXT`);
|
|
173
|
+
this.db.run("CREATE TABLE IF NOT EXISTS task_stalls (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
|
|
174
|
+
// Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
|
|
175
|
+
this.db.run("CREATE TABLE IF NOT EXISTS board_status_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL)");
|
|
176
|
+
this.db.run("INSERT OR IGNORE INTO board_status_revision VALUES (1,0)");
|
|
148
177
|
if (!have.has("reserved")) this.db.run("ALTER TABLE tasks ADD COLUMN reserved TEXT");
|
|
149
178
|
if (!have.has("external_repo")) this.db.run("ALTER TABLE tasks ADD COLUMN external_repo TEXT");
|
|
150
179
|
if (!have.has("external_issue")) this.db.run("ALTER TABLE tasks ADD COLUMN external_issue INTEGER");
|
|
@@ -168,9 +197,9 @@ export class Board {
|
|
|
168
197
|
propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; profile?: import("./profiles.ts").ProfileName; profileClass?: string; customProfile?: true; role?: string; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; reserved?: PeerId; signals?: string[]; backlog?: boolean; external?: GitHubSource }): Task {
|
|
169
198
|
const now = Date.now();
|
|
170
199
|
const history: HistoryEntry[] = [{ at: now, by, event: t.backlog ? "backlogged" : "proposed" }];
|
|
171
|
-
const { lastInsertRowid } = this.db
|
|
200
|
+
const { lastInsertRowid } = this.taskWrite(() => this.db
|
|
172
201
|
.query("INSERT INTO tasks (title, detail, class, state, refs, plan, deps, reserved, signals, history, created, updated, project_profile, task_role, external_repo, external_issue) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
173
|
-
.run(t.title, t.detail ?? "", t.class, t.backlog ? "backlog" : "proposed", JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), t.reserved ?? null, JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now, t.profile && (t.profile !== "dev" || t.customProfile || t.profileClass !== undefined && t.profileClass !== t.class) ? JSON.stringify({ profile: t.profile, profileClass: t.profileClass ?? t.class }) : null, t.role ?? null, t.external?.repo.toLowerCase() ?? null, t.external?.issue ?? null);
|
|
202
|
+
.run(t.title, t.detail ?? "", t.class, t.backlog ? "backlog" : "proposed", JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), t.reserved ?? null, JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now, t.profile && (t.profile !== "dev" || t.customProfile || t.profileClass !== undefined && t.profileClass !== t.class) ? JSON.stringify({ profile: t.profile, profileClass: t.profileClass ?? t.class }) : null, t.role ?? null, t.external?.repo.toLowerCase() ?? null, t.external?.issue ?? null));
|
|
174
203
|
const task = this.get(Number(lastInsertRowid))!;
|
|
175
204
|
this.changed(task, history[0]!);
|
|
176
205
|
return task;
|
|
@@ -193,18 +222,46 @@ export class Board {
|
|
|
193
222
|
|
|
194
223
|
/** Structural status facts only; avoid decoding text, plans and growing history on each status write. */
|
|
195
224
|
statusTasks(): Pick<Task, "id" | "state" | "owner" | "reviewer">[] {
|
|
196
|
-
return this.db.query("SELECT id, state, owner, reviewer FROM tasks WHERE state
|
|
225
|
+
return this.db.query("SELECT id, state, owner, reviewer FROM tasks WHERE state NOT IN ('approved','cancelled') ORDER BY id").all() as Pick<Task, "id" | "state" | "owner" | "reviewer">[];
|
|
197
226
|
}
|
|
198
227
|
statusReservations(): string[] {
|
|
199
|
-
return (this.db.query("SELECT DISTINCT reserved FROM tasks WHERE state NOT IN ('approved', 'backlog') AND reserved IS NOT NULL ORDER BY reserved").all() as { reserved: string }[]).map(row => row.reserved);
|
|
228
|
+
return (this.db.query("SELECT DISTINCT reserved FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND reserved IS NOT NULL ORDER BY reserved").all() as { reserved: string }[]).map(row => row.reserved);
|
|
200
229
|
}
|
|
201
230
|
plannerWip(peer: string, except?: number): number {
|
|
202
|
-
const row = this.db.query(`SELECT COUNT(*) AS n FROM tasks WHERE state NOT IN ('approved', 'backlog') AND (? IS NULL OR id != ?) AND
|
|
231
|
+
const row = this.db.query(`SELECT COUNT(*) AS n FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND (? IS NULL OR id != ?) AND
|
|
203
232
|
(owner = ? OR (owner IS NULL AND state = 'proposed' AND reserved = ? AND
|
|
204
233
|
json_extract((SELECT value FROM json_each(tasks.history) WHERE json_extract(value, '$.event') IN ('backlog pulled', 'backlog returned') ORDER BY CAST(key AS INTEGER) DESC LIMIT 1), '$.planning.pull') = 1))`).get(except ?? null, except ?? null, peer, peer) as { n: number };
|
|
205
234
|
return row.n;
|
|
206
235
|
}
|
|
207
236
|
|
|
237
|
+
recordTaskOffer(id: number, offer: TaskOffer, expectedFlowRevision: number): void {
|
|
238
|
+
const task = this.get(id);
|
|
239
|
+
if (!task || task.owner !== offer.owner || isClosedTaskState(task.state) || task.history.length !== expectedFlowRevision || offer.assignmentFlowRevision !== expectedFlowRevision) throw new Error("task offer changed");
|
|
240
|
+
const changed = this.taskWrite(() => this.db.query("UPDATE tasks SET offer=? WHERE id=? AND owner=? AND history=?").run(JSON.stringify(offer), id, offer.owner, JSON.stringify(task.history)));
|
|
241
|
+
if (!changed.changes) throw new Error("task offer changed");
|
|
242
|
+
}
|
|
243
|
+
recordStall(stall: TaskStall): boolean {
|
|
244
|
+
const old = this.stalls().find(row => row.task === stall.task);
|
|
245
|
+
if (old?.signature === stall.signature && old.replanRevision === stall.replanRevision) return false;
|
|
246
|
+
this.taskWrite(() => this.db.query("INSERT INTO task_stalls(task,payload) VALUES(?,?) ON CONFLICT(task) DO UPDATE SET payload=excluded.payload").run(stall.task, JSON.stringify(stall)));
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
clearStall(task: number): void { this.taskWrite(() => this.db.query("DELETE FROM task_stalls WHERE task=?").run(task)); }
|
|
250
|
+
stalls(): TaskStall[] {
|
|
251
|
+
return (this.db.query("SELECT payload FROM task_stalls ORDER BY task").all() as { payload: string }[]).flatMap(row => {
|
|
252
|
+
try {
|
|
253
|
+
const value = JSON.parse(row.payload) as TaskStall, cause = value.cause;
|
|
254
|
+
const peer = (id: unknown) => typeof id === "string" && /^[a-z][a-z0-9_-]{0,63}$/i.test(id);
|
|
255
|
+
if (Object.keys(value).some(key => !["task", "signature", "replanRevision", "cause", "at"].includes(key)) || !Number.isSafeInteger(value.task) || value.task < 1 || !Number.isSafeInteger(value.replanRevision) || value.replanRevision < 0 || !Number.isFinite(value.at)
|
|
256
|
+
|| !cause || !["reservation", "no-eligible-peer", "cancelled-dependency"].includes(cause.kind) || !TASK_STALL_CODES.includes(cause.code)
|
|
257
|
+
|| cause.peer !== undefined && !peer(cause.peer) || cause.dependency !== undefined && (!Number.isSafeInteger(cause.dependency) || cause.dependency < 1)
|
|
258
|
+
|| cause.refusals !== undefined && (!Array.isArray(cause.refusals) || cause.refusals.length > 64 || cause.refusals.some(row => Object.keys(row).some(key => !["peer", "code"].includes(key)) || !peer(row.peer) || !TASK_STALL_CODES.includes(row.code)))
|
|
259
|
+
|| Object.keys(cause).some(key => !["kind", "peer", "dependency", "code", "refusals"].includes(key)) || value.signature !== JSON.stringify(cause)) return [];
|
|
260
|
+
return [value];
|
|
261
|
+
} catch { return []; }
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
208
265
|
/** Tasks per state, without parsing a single row: `status.json` is rewritten on every bus event and the board only grows. */
|
|
209
266
|
counts(): Record<string, number> {
|
|
210
267
|
const rows = this.db.query("SELECT state, COUNT(*) AS n FROM tasks GROUP BY state").all() as { state: string; n: number }[];
|
|
@@ -213,9 +270,10 @@ export class Board {
|
|
|
213
270
|
|
|
214
271
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
215
272
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
216
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld"> = {}): Task {
|
|
273
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld"> = {}, expectedFlowRevision?: number): Task {
|
|
217
274
|
const task = this.get(id);
|
|
218
275
|
if (!task) throw new Error(`no task #${id}`);
|
|
276
|
+
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
219
277
|
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state)) {
|
|
220
278
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
221
279
|
}
|
|
@@ -223,10 +281,10 @@ export class Board {
|
|
|
223
281
|
if (next.state === "backlog" && (next.owner || next.reviewer)) throw new Error(`task #${id}: backlog cannot have an owner or reviewer`);
|
|
224
282
|
if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
|
|
225
283
|
if (patch.state === "backlog" && taskHasStarted(task)) throw new Error(`task #${id}: started work cannot return to backlog`);
|
|
226
|
-
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}) }];
|
|
227
|
-
this.db
|
|
228
|
-
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, updated = ? WHERE id = ?")
|
|
229
|
-
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, Date.now(), id);
|
|
284
|
+
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}) }];
|
|
285
|
+
this.taskWrite(() => this.db
|
|
286
|
+
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
|
|
287
|
+
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id));
|
|
230
288
|
const updated = this.get(id)!;
|
|
231
289
|
this.changed(updated, history.at(-1)!);
|
|
232
290
|
return updated;
|
|
@@ -242,8 +300,8 @@ export class Board {
|
|
|
242
300
|
if (next.release != null && !this.release(next.release)) throw new Error("no such release");
|
|
243
301
|
const fields = Object.keys(patch) as (keyof PlanningFields)[], previous = Object.fromEntries(fields.map(key => [key, task[key] ?? (key === "pinned" ? false : key === "deps" ? [] : null)])) as PlanningFields;
|
|
244
302
|
const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "planned", planning: { revision: revision + 1, fields, previous }, ...(note ? { note } : {}), ...(withheld && note ? { withheld: true as const } : {}) };
|
|
245
|
-
const result = this.db.query(`UPDATE tasks SET rank=?, priority=?, "release"=?, planned_start=?, due=?, pinned=?, deps=?, reserved=?, planning_revision=planning_revision+1, history=?, updated=? WHERE id=? AND planning_revision=?`)
|
|
246
|
-
.run(next.rank ?? null, next.priority ?? null, next.release ?? null, next.plannedStart ?? null, next.due ?? null, next.pinned ? 1 : 0, JSON.stringify(next.deps ?? []), next.reserved ?? null, JSON.stringify([...task.history, entry]), now, id, revision);
|
|
303
|
+
const result = this.taskWrite(() => this.db.query(`UPDATE tasks SET rank=?, priority=?, "release"=?, planned_start=?, due=?, pinned=?, deps=?, reserved=?, planning_revision=planning_revision+1, history=?, updated=? WHERE id=? AND planning_revision=?`)
|
|
304
|
+
.run(next.rank ?? null, next.priority ?? null, next.release ?? null, next.plannedStart ?? null, next.due ?? null, next.pinned ? 1 : 0, JSON.stringify(next.deps ?? []), next.reserved ?? null, JSON.stringify([...task.history, entry]), now, id, revision));
|
|
247
305
|
if (result.changes !== 1) throw new Error("planning revision changed");
|
|
248
306
|
const updated = this.get(id)!; this.changed(updated, entry); return updated;
|
|
249
307
|
}
|
|
@@ -253,8 +311,8 @@ export class Board {
|
|
|
253
311
|
const task = this.get(id);
|
|
254
312
|
if (!task || (task.planningRevision ?? 0) !== revision || task.state !== "backlog" || task.owner || taskHasStarted(task)) throw new Error("planner pull changed or already started");
|
|
255
313
|
const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "backlog pulled", planning: { revision: revision + 1, fields: ["reserved"], previous: { reserved: task.reserved ?? null }, pull: true }, note, ...(withheld ? { withheld: true as const } : {}) };
|
|
256
|
-
const result = this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
|
|
257
|
-
.run(reserved, JSON.stringify([...task.history, entry]), now, id, revision);
|
|
314
|
+
const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
|
|
315
|
+
.run(reserved, JSON.stringify([...task.history, entry]), now, id, revision));
|
|
258
316
|
if (result.changes !== 1) throw new Error("planner pull changed");
|
|
259
317
|
const updated = this.get(id)!; this.changed(updated, entry); return updated;
|
|
260
318
|
}
|
|
@@ -264,8 +322,8 @@ export class Board {
|
|
|
264
322
|
const task = this.get(id), pull = task ? planningUndoEntry(task)?.planning : undefined;
|
|
265
323
|
if (!task || task.state !== "proposed" || task.owner || taskHasStarted(task) || (task.planningRevision ?? 0) !== revision || !pull?.pull || pull.revision !== revision) throw new Error("planner pull undo changed or already offered");
|
|
266
324
|
const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "backlog returned", note: `undo planner pull revision ${revision}` };
|
|
267
|
-
const result = this.db.query("UPDATE tasks SET state='backlog',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='proposed' AND owner IS NULL")
|
|
268
|
-
.run(pull.previous.reserved ?? null, JSON.stringify([...task.history, entry]), now, id, revision);
|
|
325
|
+
const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='backlog',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='proposed' AND owner IS NULL")
|
|
326
|
+
.run(pull.previous.reserved ?? null, JSON.stringify([...task.history, entry]), now, id, revision));
|
|
269
327
|
if (result.changes !== 1) throw new Error("planner pull undo changed");
|
|
270
328
|
const updated = this.get(id)!; this.changed(updated, entry); return updated;
|
|
271
329
|
}
|
|
@@ -287,7 +345,7 @@ export class Board {
|
|
|
287
345
|
const updated = this.plan(id, by, { deps }, revision, note, withheld);
|
|
288
346
|
const entry = updated.history.at(-1)!;
|
|
289
347
|
entry.planning!.split = { children: children.map(child => ({ id: child.id, revision: child.planningRevision ?? 0, history: child.history.length })) };
|
|
290
|
-
this.db.query("UPDATE tasks SET history=? WHERE id=?").run(JSON.stringify(updated.history), id);
|
|
348
|
+
this.taskWrite(() => this.db.query("UPDATE tasks SET history=? WHERE id=?").run(JSON.stringify(updated.history), id));
|
|
291
349
|
const notification = this.pendingChanges?.findLast(change => change.task.id === id && change.entry.planning?.revision === revision + 1);
|
|
292
350
|
if (notification) { notification.task = updated; notification.entry = entry; }
|
|
293
351
|
return { parent: this.get(id)!, children };
|
|
@@ -305,7 +363,7 @@ export class Board {
|
|
|
305
363
|
}
|
|
306
364
|
if (this.list().some(task => task.id !== id && !ids.has(task.id) && (task.deps?.some(dep => ids.has(dep)) || task.sdlc?.phase?.tasks.some(ref => ids.has(ref.id) || ref.after.some(dep => ids.has(dep)))))) throw new Error("split children are referenced by other work");
|
|
307
365
|
const restored = this.plan(id, by, { deps: entry!.planning!.previous.deps ?? [] }, revision, `undo split revision ${revision}`);
|
|
308
|
-
for (const child of ids) this.db.query("DELETE FROM tasks WHERE id=?").run(child);
|
|
366
|
+
for (const child of ids) this.taskWrite(() => this.db.query("DELETE FROM tasks WHERE id=?").run(child));
|
|
309
367
|
return restored;
|
|
310
368
|
});
|
|
311
369
|
}
|
|
@@ -373,6 +431,7 @@ export interface ReviewOutcome {
|
|
|
373
431
|
|
|
374
432
|
function parse(row: Record<string, unknown>): Task {
|
|
375
433
|
const out = { ...row } as Record<string, unknown>;
|
|
434
|
+
for (const key of ["offer", "cancelled"]) if (row[key] == null) delete out[key]; else out[key] = JSON.parse(String(row[key]));
|
|
376
435
|
for (const col of JSON_COLS) {
|
|
377
436
|
if (col === "sdlc" && row[col] == null) delete out[col];
|
|
378
437
|
else out[col] = JSON.parse(String(row[col]));
|