@staix/agent-hub 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +9 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +139 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +22 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +307 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +18 -5
- package/docs/security.md +154 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4393 -3609
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +77 -16
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +493 -53
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1569 -134
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peers.ts +25 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +138 -7
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
package/src/hub/board.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { parseAttemptReceipt, parseResearchRunners, verifyAttemptReceipt, type AttemptReceiptV1 } from "./research-runner.ts";
|
|
2
2
|
import { canonicalJson } from "./packs.ts";
|
|
3
|
+
import { parseEvidenceLink, evidenceLinkCanonical, MAX_EVIDENCE_RECORD_BYTES, type EvidenceLinkV1 } from "./research-evidence.ts";
|
|
4
|
+
import { parseStoredReviewComment, reviewCommentIds, emptyReviewCommentCounts, REVIEW_COMMENT_LIMIT, type ReviewCommentRecord, type ReviewCommentFence, type ReviewCommentCounts, type ReviewCommentPackage, type ReviewCommentPackageView } from "./review-comments.ts";
|
|
3
5
|
import { parseComparisonPlan, comparisonPlanHash, evaluatorIdentityHash, parseExperimentProtocol, protocolHash, parseExperimentAttempt, parseExperimentEvent, parseExperimentHostEvidence, experimentStatus, projectExperimentWithObservations, declaredExperimentEvents,
|
|
4
6
|
type ComparisonPlanV1, type ExperimentProtocolRecord, type ExperimentAttemptRecord, type ExperimentEventRecord, type ExperimentWriteFence, type DeclaredExperimentEventInput, type ExperimentHostEvidence, type ExperimentStatus } from "./research-experiments.ts";
|
|
5
7
|
import type { InvocationRow } from "./invocations.ts";
|
|
@@ -249,6 +251,7 @@ export interface HistoryEntry {
|
|
|
249
251
|
/** Opaque artifact identity captured by the host at the queue-held role write. */
|
|
250
252
|
queueReviewBinding?: string;
|
|
251
253
|
reviewerFailure?: { peer: string; head?: string; cause: "start_failed" | "quota" | "no_verdict" };
|
|
254
|
+
resolvedReviewComments?: string[];
|
|
252
255
|
planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
|
|
253
256
|
}
|
|
254
257
|
/** #901: one person verdict on a plan claim, with the model label the person says produced the claim. All closed or bounded; nothing here is claim text. */
|
|
@@ -259,6 +262,14 @@ export interface ClaimVerdict { slot: number; verdict: (typeof CLAIM_VERDICTS)[n
|
|
|
259
262
|
export function claimListId(claims: readonly string[] | undefined): string {
|
|
260
263
|
return createHash("sha256").update(planFingerprint({ claims: [...(claims ?? [])] })).digest("hex").slice(0, 16);
|
|
261
264
|
}
|
|
265
|
+
/** #901's one claim identity boundary, shared by person verdicts and declared passage links. */
|
|
266
|
+
export function taskClaimIdentity(task: Pick<Task, "id" | "created" | "plan">, slot: number): { claimId: string; claimRevision: string; slot: number } {
|
|
267
|
+
if (!Number.isSafeInteger(task.id) || task.id < 1 || !Number.isSafeInteger(task.created) || task.created < 0
|
|
268
|
+
|| !Number.isSafeInteger(slot) || slot < 1 || slot > Math.min(50, task.plan?.claims?.length ?? 0)) throw new Error("claim slot is unavailable; refresh ahub task show and use a current declared claim slot");
|
|
269
|
+
// ponytail: revision binds the whole ordered list, so another claim's edit invalidates every link.
|
|
270
|
+
// Upgrade to shared per-claim revision bindings at this #901 boundary, preserving exact-list restoration of existing verdicts.
|
|
271
|
+
return { claimId: `claim:${task.id}:${task.created}:${slot}`, claimRevision: claimListId(task.plan?.claims), slot };
|
|
272
|
+
}
|
|
262
273
|
/** A claim slot is a position in the plan's claim list (at most 50, as the scorecard's own bound); a model label is a bounded token, never prose. */
|
|
263
274
|
export function parseClaimVerdict(value: unknown, slots: number): ClaimVerdict {
|
|
264
275
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("claim verdict must be an object");
|
|
@@ -320,9 +331,12 @@ function plannerMetadata(planner: unknown, reason?: PlannerReason): Pick<History
|
|
|
320
331
|
return { planner: plannerValue(planner), ...(reason === undefined ? {} : { plannerReason: reason }) };
|
|
321
332
|
}
|
|
322
333
|
/** #604: what this project handed to another project, and the last state that project reported (agent-hub#1030 design 4). */
|
|
323
|
-
export interface HandoffOutboundRow { remoteProject: string; remoteTask: number; originPeer: string; localTask: number | null; title: string; state: string; pr: number | null; head: string | null; updatedAt: number
|
|
334
|
+
export interface HandoffOutboundRow { remoteProject: string; remoteTask: number; originPeer: string; localTask: number | null; title: string; state: string; pr: number | null; head: string | null; updatedAt: number;
|
|
335
|
+
/** #733: the target task's flow revision at the last event applied; null for a row from before sequences or from a target that sends none. */
|
|
336
|
+
seq: number | null }
|
|
337
|
+
const OUTBOUND_COLUMNS = "remote_project, remote_task, origin_peer, local_task, title, last_state, pr, head, updated_at, seq";
|
|
324
338
|
const handoffOutboundFromRow = (row: Record<string, unknown>): HandoffOutboundRow => ({ remoteProject: String(row.remote_project), remoteTask: Number(row.remote_task), originPeer: String(row.origin_peer),
|
|
325
|
-
localTask: row.local_task === null ? null : Number(row.local_task), title: String(row.title), state: String(row.last_state), pr: row.pr === null ? null : Number(row.pr), head: row.head === null ? null : String(row.head), updatedAt: Number(row.updated_at) });
|
|
339
|
+
localTask: row.local_task === null ? null : Number(row.local_task), title: String(row.title), state: String(row.last_state), pr: row.pr === null ? null : Number(row.pr), head: row.head === null ? null : String(row.head), updatedAt: Number(row.updated_at), seq: row.seq === null || row.seq === undefined ? null : Number(row.seq) });
|
|
326
340
|
|
|
327
341
|
export interface Task extends PlanningFields {
|
|
328
342
|
offer?: TaskOffer;
|
|
@@ -355,6 +369,8 @@ export interface Task extends PlanningFields {
|
|
|
355
369
|
deps?: number[];
|
|
356
370
|
/** Who routing offers the task first once nothing is left to wait for (issue #207); null when nobody was named. */
|
|
357
371
|
reserved?: PeerId | null;
|
|
372
|
+
/** #1176: only the person can run this leg. Present (true) only when set; no agent is ever offered it, and it counts toward no planner's WIP. */
|
|
373
|
+
needsPerson?: true;
|
|
358
374
|
signals: string[];
|
|
359
375
|
/** consecutive changes_requested verdicts */
|
|
360
376
|
rejections: number;
|
|
@@ -407,6 +423,8 @@ const TASK_SELECT = "SELECT tasks.*, (SELECT CASE WHEN length(CAST(payload AS BL
|
|
|
407
423
|
/** Task board in `.agenthub/state/hub.db`. It outlives the hub process: `ahub kill` leaves the file. */
|
|
408
424
|
export class Board {
|
|
409
425
|
private readonly db: Database;
|
|
426
|
+
private reviewObserverCacheStamp = "";
|
|
427
|
+
private readonly reviewObserverScopeCache = new Map<string, boolean>();
|
|
410
428
|
private pendingChanges: { task: Task; entry: HistoryEntry }[] | undefined;
|
|
411
429
|
/** Every recorded change, as it lands (telemetry, issue #40). */
|
|
412
430
|
onChange?: (task: Task, entry: HistoryEntry) => void;
|
|
@@ -494,6 +512,8 @@ export class Board {
|
|
|
494
512
|
if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN "${col}" INTEGER`);
|
|
495
513
|
}
|
|
496
514
|
if (!have.has("pinned")) this.db.run("ALTER TABLE tasks ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0");
|
|
515
|
+
// #1176: additive, like `pinned`; an older hub ignores the column.
|
|
516
|
+
if (!have.has("needs_person")) this.db.run("ALTER TABLE tasks ADD COLUMN needs_person INTEGER NOT NULL DEFAULT 0");
|
|
497
517
|
if (!have.has("draft")) this.db.run("ALTER TABLE tasks ADD COLUMN draft INTEGER NOT NULL DEFAULT 0");
|
|
498
518
|
if (!have.has("planning_revision")) this.db.run("ALTER TABLE tasks ADD COLUMN planning_revision INTEGER NOT NULL DEFAULT 0");
|
|
499
519
|
this.db.run(`CREATE TABLE IF NOT EXISTS releases (
|
|
@@ -524,12 +544,28 @@ export class Board {
|
|
|
524
544
|
// is the ORIGIN's record of what it handed to another project and the last state that project reported. Both are additive: a daemon without `task_handoff` never reads them, so a target
|
|
525
545
|
// lacking the feature is refused at recovery while either holds a row.
|
|
526
546
|
this.db.run("CREATE TABLE IF NOT EXISTS task_handoff_origin (task_id INTEGER PRIMARY KEY, project_id TEXT NOT NULL, peer TEXT NOT NULL, origin_task INTEGER, at INTEGER NOT NULL)");
|
|
547
|
+
// #731 (agent-hub#1203): the origin's idempotency key and a digest of what it asked for. A key is unique per origin project, so a replay can never file a second task. Rows from before keep NULL.
|
|
548
|
+
for (const [column, type] of [["handoff_key", "TEXT"], ["request_digest", "TEXT"]] as const)
|
|
549
|
+
if (!(this.db.query("PRAGMA table_info(task_handoff_origin)").all() as { name: string }[]).some(c => c.name === column)) this.db.run(`ALTER TABLE task_handoff_origin ADD COLUMN ${column} ${type}`);
|
|
550
|
+
this.db.run("CREATE UNIQUE INDEX IF NOT EXISTS task_handoff_origin_key ON task_handoff_origin(project_id, handoff_key) WHERE handoff_key IS NOT NULL");
|
|
527
551
|
this.db.run(`CREATE TABLE IF NOT EXISTS task_handoff_outbound (remote_project TEXT NOT NULL, remote_task INTEGER NOT NULL, origin_peer TEXT NOT NULL, local_task INTEGER,
|
|
528
552
|
title TEXT NOT NULL, last_state TEXT NOT NULL, pr INTEGER, head TEXT, updated_at INTEGER NOT NULL, PRIMARY KEY(remote_project, remote_task))`);
|
|
553
|
+
// #733: the sequence of the last applied event, so an event that arrives late cannot move a row backwards. Rows from before keep NULL and accept any event.
|
|
554
|
+
if (!(this.db.query("PRAGMA table_info(task_handoff_outbound)").all() as { name: string }[]).some(c => c.name === "seq")) this.db.run("ALTER TABLE task_handoff_outbound ADD COLUMN seq INTEGER");
|
|
529
555
|
this.db.run(`CREATE TABLE IF NOT EXISTS research_handoffs (
|
|
530
556
|
campaign_id TEXT NOT NULL, unit_id TEXT NOT NULL, seq INTEGER NOT NULL, revision INTEGER NOT NULL,
|
|
531
557
|
task_id INTEGER NOT NULL, body TEXT NOT NULL, by_peer TEXT NOT NULL, at INTEGER NOT NULL, private INTEGER NOT NULL,
|
|
532
558
|
PRIMARY KEY(campaign_id,unit_id,seq))`);
|
|
559
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_evidence_links (
|
|
560
|
+
project TEXT NOT NULL, id TEXT NOT NULL, revision INTEGER NOT NULL, task_id INTEGER NOT NULL,
|
|
561
|
+
body TEXT NOT NULL, body_sha256 TEXT NOT NULL, PRIMARY KEY(project,id,revision))`);
|
|
562
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS review_comments (
|
|
563
|
+
task_id INTEGER NOT NULL,task_created INTEGER NOT NULL,id TEXT NOT NULL,body TEXT NOT NULL,lifecycle TEXT NOT NULL,revision INTEGER NOT NULL,
|
|
564
|
+
PRIMARY KEY(task_id,task_created,id))`);
|
|
565
|
+
this.db.run("CREATE INDEX IF NOT EXISTS review_comments_sent ON review_comments(task_id,task_created,id) WHERE lifecycle='sent'");
|
|
566
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS review_comment_packages (
|
|
567
|
+
id TEXT PRIMARY KEY,task_id INTEGER NOT NULL,task_created INTEGER NOT NULL,body TEXT NOT NULL)`);
|
|
568
|
+
this.db.run("CREATE INDEX IF NOT EXISTS review_comment_packages_owner ON review_comment_packages(CASE WHEN json_valid(body)=1 THEN json_extract(body,'$.owner') END,task_id,task_created)");
|
|
533
569
|
this.db.run(`CREATE TABLE IF NOT EXISTS research_protocols (
|
|
534
570
|
hash TEXT PRIMARY KEY, project TEXT NOT NULL, campaign_id TEXT NOT NULL, campaign_revision INTEGER NOT NULL, task_id INTEGER NOT NULL, body TEXT NOT NULL,
|
|
535
571
|
by_peer TEXT NOT NULL, at INTEGER NOT NULL, private INTEGER NOT NULL)`);
|
|
@@ -576,26 +612,72 @@ export class Board {
|
|
|
576
612
|
this.db.query("INSERT INTO task_handoff_origin(task_id, project_id, peer, origin_task, at) VALUES(?,?,?,?,?) ON CONFLICT(task_id) DO NOTHING").run(taskId, origin.projectId, origin.peer, origin.originTask ?? null, Date.now());
|
|
577
613
|
}
|
|
578
614
|
|
|
579
|
-
/**
|
|
615
|
+
/**
|
|
616
|
+
* #731: the task a handoff key already made for this origin project, with the digest of the request that made it, or undefined. A read: a replayed key is answered from here.
|
|
617
|
+
*/
|
|
618
|
+
handoffOriginByKey(projectId: string, key: string): { taskId: number; digest: string | null } | undefined {
|
|
619
|
+
const row = this.db.query("SELECT task_id, request_digest FROM task_handoff_origin WHERE project_id=? AND handoff_key=?").get(projectId, key) as { task_id: number; request_digest: string | null } | null;
|
|
620
|
+
return row ? { taskId: row.task_id, digest: row.request_digest } : undefined;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* #731 (H9): the proposal and its origin row in ONE transaction, so a crash or a failure between them can never leave a task with no origin (which the origin could then never be told about),
|
|
624
|
+
* and a replayed key answers with the task it already made instead of filing a second one. The unique index is the last guard: even two writers of one key cannot both commit.
|
|
625
|
+
* Observers (`onChange`) run after the commit, as for every other multi-row write.
|
|
626
|
+
*/
|
|
627
|
+
proposeHandoff(by: PeerId, t: Parameters<Board["propose"]>[1], origin: { projectId: string; peer: string; originTask?: number; key?: string; digest?: string }): { task: Task; replayed: boolean } {
|
|
628
|
+
return this.atomic(() => {
|
|
629
|
+
if (origin.key !== undefined) {
|
|
630
|
+
const existing = this.handoffOriginByKey(origin.projectId, origin.key);
|
|
631
|
+
if (existing) { const task = this.get(existing.taskId); if (task) return { task, replayed: true }; }
|
|
632
|
+
}
|
|
633
|
+
const task = this.propose(by, t);
|
|
634
|
+
this.db.query("INSERT INTO task_handoff_origin(task_id, project_id, peer, origin_task, at, handoff_key, request_digest) VALUES(?,?,?,?,?,?,?)")
|
|
635
|
+
.run(task.id, origin.projectId, origin.peer, origin.originTask ?? null, Date.now(), origin.key ?? null, origin.digest ?? null);
|
|
636
|
+
return { task, replayed: false };
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* ORIGIN side: what this project handed to another one, newest first, for the person's list (`ahub board --handoffs`). Keyed by the target project and its task id, so a repeated event updates
|
|
642
|
+
* one row. Capped for display only: the catch-up never reads this (`handoffOutboundOpen`), so a row the list does not show is still caught up (#733).
|
|
643
|
+
*/
|
|
580
644
|
handoffOutbound(): HandoffOutboundRow[] {
|
|
581
|
-
return (this.db.query(
|
|
645
|
+
return (this.db.query(`SELECT ${OUTBOUND_COLUMNS} FROM task_handoff_outbound ORDER BY updated_at DESC, remote_project, remote_task LIMIT 200`).all() as Record<string, unknown>[]).map(handoffOutboundFromRow);
|
|
646
|
+
}
|
|
647
|
+
/** #733 (H5): every row the catch-up still asks about, no cap, oldest first so a long backlog is worked through in the same order every pass. The caller filters with `handoffCatchUpRow`. */
|
|
648
|
+
handoffOutboundOpen(): HandoffOutboundRow[] {
|
|
649
|
+
return (this.db.query(`SELECT ${OUTBOUND_COLUMNS} FROM task_handoff_outbound WHERE last_state != 'cancelled' ORDER BY updated_at, remote_project, remote_task`).all() as Record<string, unknown>[]).map(handoffOutboundFromRow);
|
|
582
650
|
}
|
|
583
651
|
handoffOutboundFor(remoteProject: string, remoteTask: number): HandoffOutboundRow | undefined {
|
|
584
|
-
const row = this.db.query(
|
|
652
|
+
const row = this.db.query(`SELECT ${OUTBOUND_COLUMNS} FROM task_handoff_outbound WHERE remote_project=? AND remote_task=?`).get(remoteProject, remoteTask) as Record<string, unknown> | null;
|
|
585
653
|
return row ? handoffOutboundFromRow(row) : undefined;
|
|
586
654
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
655
|
+
/**
|
|
656
|
+
* Record a state. With a `seq` the write applies only when it is newer than the stored one (#733 H4: events use a connection each and can arrive out of order), decided inside the one statement so
|
|
657
|
+
* two events cannot both pass a check made before either wrote. Returns whether the row changed. An event with no `seq` (an older target, or the origin's own first write) behaves as before:
|
|
658
|
+
* it applies, and keeps the highest `seq` already stored.
|
|
659
|
+
*/
|
|
660
|
+
recordHandoffOutbound(row: { remoteProject: string; remoteTask: number; originPeer: string; localTask?: number; title: string; state: string; pr?: number; head?: string; seq?: number }): { applied: boolean } {
|
|
661
|
+
const result = this.db.query(`INSERT INTO task_handoff_outbound(remote_project, remote_task, origin_peer, local_task, title, last_state, pr, head, updated_at, seq) VALUES(?,?,?,?,?,?,?,?,?,?)
|
|
662
|
+
ON CONFLICT(remote_project, remote_task) DO UPDATE SET last_state=excluded.last_state, pr=COALESCE(excluded.pr, pr), head=COALESCE(excluded.head, head), updated_at=excluded.updated_at, seq=COALESCE(MAX(excluded.seq, seq), excluded.seq, seq)
|
|
663
|
+
WHERE excluded.seq IS NULL OR seq IS NULL OR excluded.seq > seq`)
|
|
664
|
+
.run(row.remoteProject, row.remoteTask, row.originPeer, row.localTask ?? null, row.title, row.state, row.pr ?? null, row.head ?? null, Date.now(), row.seq ?? null);
|
|
665
|
+
return { applied: result.changes > 0 };
|
|
591
666
|
}
|
|
592
667
|
/** A digest over both tables, so a recovery that restores or loses a row is seen. */
|
|
593
668
|
handoffIntegrity(): { origins: number; outbound: number; digest: string } {
|
|
594
669
|
return this.db.transaction(() => {
|
|
595
670
|
const digest = createHash("sha256").update("agent-hub.handoff-integrity/v1\n");
|
|
596
671
|
let origins = 0, outbound = 0;
|
|
597
|
-
for (const row of this.db.query("SELECT task_id, project_id, peer, origin_task, at FROM task_handoff_origin ORDER BY task_id").iterate()
|
|
598
|
-
|
|
672
|
+
for (const row of this.db.query("SELECT task_id, project_id, peer, origin_task, at, handoff_key, request_digest FROM task_handoff_origin ORDER BY task_id").iterate() as Iterable<Record<string, unknown>>) {
|
|
673
|
+
// A row from before handoff keys has no key columns in its digest, so an upgrade does not change what a recovery compares (#731).
|
|
674
|
+
if (row.handoff_key === null) { delete row.handoff_key; delete row.request_digest; }
|
|
675
|
+
digest.update(JSON.stringify(row)).update("\n"); origins++;
|
|
676
|
+
}
|
|
677
|
+
for (const row of this.db.query(`SELECT ${OUTBOUND_COLUMNS} FROM task_handoff_outbound ORDER BY remote_project, remote_task`).iterate() as Iterable<Record<string, unknown>>) {
|
|
678
|
+
if (row.seq === null) delete row.seq; // a row from before sequences keeps its digest (#733)
|
|
679
|
+
digest.update(JSON.stringify(row)).update("\n"); outbound++;
|
|
680
|
+
}
|
|
599
681
|
return { origins, outbound, digest: digest.digest("hex") };
|
|
600
682
|
})();
|
|
601
683
|
}
|
|
@@ -912,6 +994,257 @@ export class Board {
|
|
|
912
994
|
});
|
|
913
995
|
}
|
|
914
996
|
|
|
997
|
+
/** Immutable declared link revisions. Caller/source/privacy admission belongs to Tasks. */
|
|
998
|
+
evidenceLink(project: string, id: string, revision?: number): EvidenceLinkV1 | undefined {
|
|
999
|
+
if (!researchText(project) || !/^ev:[a-f0-9]{32}$/.test(id) || revision !== undefined && !researchInteger(revision)) throw new Error("invalid evidence identity; use the recorded evidence id and revision");
|
|
1000
|
+
const row = this.db.query(`SELECT project,id,revision,task_id,CASE WHEN length(CAST(body AS BLOB))<=${MAX_EVIDENCE_RECORD_BYTES} THEN body END AS body,body_sha256
|
|
1001
|
+
FROM research_evidence_links WHERE project=? AND id=?${revision === undefined ? "" : " AND revision=?"} ORDER BY revision DESC LIMIT 1`)
|
|
1002
|
+
.get(...(revision === undefined ? [project, id] : [project, id, revision])) as { project: string; id: string; revision: number; task_id: number; body: string | null; body_sha256: string } | null;
|
|
1003
|
+
if (!row) return undefined;
|
|
1004
|
+
try {
|
|
1005
|
+
if (typeof row.body !== "string") throw new Error("invalid body");
|
|
1006
|
+
const link = parseEvidenceLink(JSON.parse(row.body)), canonical = evidenceLinkCanonical(link);
|
|
1007
|
+
if (link.id !== row.id || link.revision !== row.revision || link.taskId !== row.task_id || createHash("sha256").update(canonical).digest("hex") !== row.body_sha256) throw new Error("invalid binding");
|
|
1008
|
+
return link;
|
|
1009
|
+
} catch { throw new Error("evidence storage invalid; stop the hub and restore a trusted hub.db backup; then restart and retry"); }
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
recordEvidenceLink(project: string, input: EvidenceLinkV1, fence: { taskFlowRevision: number; taskCreated: number; owner: PeerId }): EvidenceLinkV1 {
|
|
1013
|
+
const link = parseEvidenceLink(input), canonical = evidenceLinkCanonical(link);
|
|
1014
|
+
if (!researchText(project) || !researchInteger(fence.taskFlowRevision, 0) || !researchInteger(fence.taskCreated, 0) || fence.owner !== link.recordedBy) throw new Error("evidence write binding is invalid; refresh the task before retrying");
|
|
1015
|
+
return this.atomic(() => {
|
|
1016
|
+
const task = this.get(link.taskId);
|
|
1017
|
+
if (!task || task.created !== fence.taskCreated || task.history.length !== fence.taskFlowRevision || task.owner !== fence.owner || task.state !== "in_progress" || task.profile !== "research"
|
|
1018
|
+
|| task.signals.includes("pii") || task.signals.includes("pii_path")) throw new Error("evidence task authority changed; refresh ahub task show before retrying");
|
|
1019
|
+
const identity = taskClaimIdentity(task, Number(link.claimId.split(":").at(-1)));
|
|
1020
|
+
if (identity.claimId !== link.claimId || identity.claimRevision !== link.claimRevision) throw new Error("evidence claim revision changed; refresh the claim binding before retrying");
|
|
1021
|
+
const latest = this.evidenceLink(project, link.id), next = (latest?.revision ?? 0) + 1;
|
|
1022
|
+
if (latest && (latest.taskId !== link.taskId || latest.claimId !== link.claimId)) throw new Error("evidence id belongs to another claim; use a new evidence id");
|
|
1023
|
+
const known = this.evidenceLink(project, link.id, link.revision);
|
|
1024
|
+
if (known) {
|
|
1025
|
+
if (evidenceLinkCanonical(known) !== canonical) throw new Error(`evidence revision already records different content; use next revision ${next} or a new evidence id`);
|
|
1026
|
+
return known;
|
|
1027
|
+
}
|
|
1028
|
+
if (link.revision !== next) throw new Error(`evidence revision is out of sequence; expected next revision ${next}; use that revision or replay an unchanged recorded revision`);
|
|
1029
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_evidence_links(project,id,revision,task_id,body,body_sha256) VALUES(?,?,?,?,?,?)")
|
|
1030
|
+
.run(project, link.id, link.revision, link.taskId, canonical, createHash("sha256").update(canonical).digest("hex")));
|
|
1031
|
+
return this.evidenceLink(project, link.id, link.revision)!;
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
hasResearchEvidenceState(): boolean {
|
|
1036
|
+
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_evidence_links) AS present").get() as { present: number }).present === 1;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
researchEvidenceIntegrity(): { links: number; revisions: number; digest: string } {
|
|
1040
|
+
return this.db.transaction(() => {
|
|
1041
|
+
const digest = createHash("sha256").update("agent-hub.research-evidence/v1\n");
|
|
1042
|
+
let links = 0, revisions = 0, previous: string | undefined, previousRevision = 0;
|
|
1043
|
+
let claim: string | undefined, task: number | undefined;
|
|
1044
|
+
for (const row of this.db.query("SELECT project,id,revision FROM research_evidence_links ORDER BY project,id,revision").iterate() as Iterable<{ project: string; id: string; revision: number }>) {
|
|
1045
|
+
const link = this.evidenceLink(row.project, row.id, row.revision);
|
|
1046
|
+
if (!link) throw new Error("evidence storage changed during integrity read; retry recovery inspection");
|
|
1047
|
+
const key = JSON.stringify([row.project, row.id]);
|
|
1048
|
+
if (key !== previous) { links++; previous = key; previousRevision = 0; claim = link.claimId; task = link.taskId; }
|
|
1049
|
+
if (row.revision !== previousRevision + 1 || link.claimId !== claim || link.taskId !== task) throw new Error("evidence revision history is incomplete or changed claims; stop the hub and restore a trusted hub.db backup; then restart and retry recovery");
|
|
1050
|
+
previousRevision = row.revision; revisions++;
|
|
1051
|
+
digest.update(JSON.stringify([row.project, evidenceLinkCanonical(link)])).update("\n");
|
|
1052
|
+
}
|
|
1053
|
+
return { links, revisions, digest: digest.digest("hex") };
|
|
1054
|
+
})();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
reviewComments(task: number, created: number): ReviewCommentRecord[] {
|
|
1058
|
+
const rows = this.db.query(`SELECT id,lifecycle,revision,CASE WHEN length(CAST(body AS BLOB))<=32768 THEN body END AS body FROM review_comments WHERE task_id=? AND task_created=? ORDER BY id LIMIT ${REVIEW_COMMENT_LIMIT + 1}`).all(task, created) as { id: string; lifecycle: string; revision: number; body: string | null }[];
|
|
1059
|
+
if (rows.length > REVIEW_COMMENT_LIMIT) throw new Error("review comment storage limit invalid; restore a trusted hub.db backup before retrying");
|
|
1060
|
+
return rows.map(row => {
|
|
1061
|
+
try {
|
|
1062
|
+
if (typeof row.body !== "string") throw new Error("missing body");
|
|
1063
|
+
const record = parseStoredReviewComment(JSON.parse(row.body));
|
|
1064
|
+
if (record.task !== task || record.taskCreated !== created || record.thread.id !== row.id || record.lifecycle !== row.lifecycle || record.revision !== row.revision) throw new Error("binding differs");
|
|
1065
|
+
return record;
|
|
1066
|
+
} catch { throw new Error("review comment storage invalid; stop the hub and restore a trusted hub.db backup, then restart and retry"); }
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
reviewCommentCounts(task: number, created: number): ReviewCommentCounts {
|
|
1070
|
+
const counts = emptyReviewCommentCounts();
|
|
1071
|
+
for (const row of this.db.query("SELECT lifecycle,COUNT(*) AS n FROM review_comments WHERE task_id=? AND task_created=? GROUP BY lifecycle").all(task, created) as { lifecycle: string; n: number }[]) {
|
|
1072
|
+
if (!Object.hasOwn(counts, row.lifecycle)) throw new Error("review comment storage invalid; restore a trusted hub.db backup before retrying");
|
|
1073
|
+
counts[row.lifecycle as keyof ReviewCommentCounts] = row.n;
|
|
1074
|
+
}
|
|
1075
|
+
return counts;
|
|
1076
|
+
}
|
|
1077
|
+
private reviewCommentFence(fence: ReviewCommentFence): Task {
|
|
1078
|
+
const task = this.get(fence.task);
|
|
1079
|
+
if (!task || task.created !== fence.taskCreated || task.history.length !== fence.flow || task.owner !== fence.owner || (task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead) !== fence.head) throw new Error("review task changed; refresh the task review before retrying");
|
|
1080
|
+
return task;
|
|
1081
|
+
}
|
|
1082
|
+
putReviewComment(input: ReviewCommentRecord, fence: ReviewCommentFence, expected?: number): ReviewCommentRecord {
|
|
1083
|
+
const record = parseStoredReviewComment(input);
|
|
1084
|
+
return this.atomic(() => {
|
|
1085
|
+
this.reviewCommentFence(fence);
|
|
1086
|
+
if (record.task !== fence.task || record.taskCreated !== fence.taskCreated || record.lifecycle !== "draft") throw new Error("review comment binding changed; refresh the draft before retrying");
|
|
1087
|
+
const comments = this.reviewComments(record.task, record.taskCreated), previous = comments.find(row => row.thread.id === record.thread.id);
|
|
1088
|
+
if (previous ? previous.lifecycle !== "draft" || previous.revision !== expected || record.revision !== previous.revision + 1 : expected !== undefined || record.revision !== 1 || comments.length >= REVIEW_COMMENT_LIMIT) throw new Error("review draft changed or the comment limit is full; refresh the draft list before retrying");
|
|
1089
|
+
this.taskWrite(() => this.db.query("INSERT INTO review_comments(task_id,task_created,id,body,lifecycle,revision) VALUES(?,?,?,?,?,?) ON CONFLICT(task_id,task_created,id) DO UPDATE SET body=excluded.body,lifecycle=excluded.lifecycle,revision=excluded.revision")
|
|
1090
|
+
.run(record.task, record.taskCreated, record.thread.id, JSON.stringify(record), record.lifecycle, record.revision));
|
|
1091
|
+
return this.reviewComments(record.task, record.taskCreated).find(row => row.thread.id === record.thread.id)!;
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
deleteReviewComment(id: string, revision: number, fence: ReviewCommentFence): { deleted: boolean; record: ReviewCommentRecord } {
|
|
1095
|
+
return this.atomic(() => {
|
|
1096
|
+
this.reviewCommentFence(fence);
|
|
1097
|
+
const record = this.reviewComments(fence.task, fence.taskCreated).find(row => row.thread.id === id);
|
|
1098
|
+
if (!record) throw new Error("review comment is unavailable; refresh the draft list before retrying");
|
|
1099
|
+
if (record.lifecycle === "dismissed") return { deleted: false, record };
|
|
1100
|
+
if (record.revision !== revision) throw new Error("review comment revision changed; refresh the list before retrying");
|
|
1101
|
+
if (record.lifecycle === "draft") {
|
|
1102
|
+
this.taskWrite(() => this.db.query("DELETE FROM review_comments WHERE task_id=? AND task_created=? AND id=?").run(fence.task, fence.taskCreated, id));
|
|
1103
|
+
return { deleted: true, record };
|
|
1104
|
+
}
|
|
1105
|
+
const dismissed: ReviewCommentRecord = { ...record, lifecycle: "dismissed", revision: record.revision + 1 };
|
|
1106
|
+
this.taskWrite(() => this.db.query("UPDATE review_comments SET body=?,lifecycle=?,revision=? WHERE task_id=? AND task_created=? AND id=?").run(JSON.stringify(dismissed), dismissed.lifecycle, dismissed.revision, fence.task, fence.taskCreated, id));
|
|
1107
|
+
return { deleted: false, record: dismissed };
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
sendReviewComments(selected: readonly ReviewCommentRecord[], packet: ReviewCommentPackage, fence: ReviewCommentFence): Task {
|
|
1111
|
+
return this.atomic(() => {
|
|
1112
|
+
const task = this.reviewCommentFence(fence), rows = this.reviewComments(task.id, task.created);
|
|
1113
|
+
if (!selected.length || selected.length > REVIEW_COMMENT_LIMIT || new Set(selected.map(row => row.thread.id)).size !== selected.length || selected.length !== packet.ids.length
|
|
1114
|
+
|| task.id !== packet.task || task.created !== packet.taskCreated || task.owner !== packet.owner || selected.some(row => !packet.ids.includes(row.thread.id))) throw new Error("review package binding changed; refresh the selected drafts before sending");
|
|
1115
|
+
const encodedPacket = JSON.stringify(packet);
|
|
1116
|
+
if (typeof encodedPacket !== "string" || Buffer.byteLength(encodedPacket, "utf8") > 32768) throw new Error("review package storage exceeds 32768 UTF-8 bytes; select fewer ids before sending");
|
|
1117
|
+
for (const expected of selected) {
|
|
1118
|
+
const record = rows.find(row => row.thread.id === expected.thread.id);
|
|
1119
|
+
if (!record || record.lifecycle !== "draft" || record.revision !== expected.revision) throw new Error("review drafts changed; refresh the list before sending");
|
|
1120
|
+
const sent: ReviewCommentRecord = { ...record, lifecycle: "sent", sentAt: packet.at, revision: record.revision + 1 };
|
|
1121
|
+
this.taskWrite(() => this.db.query("UPDATE review_comments SET body=?,lifecycle=?,revision=? WHERE task_id=? AND task_created=? AND id=?").run(JSON.stringify(sent), sent.lifecycle, sent.revision, task.id, task.created, record.thread.id));
|
|
1122
|
+
}
|
|
1123
|
+
this.taskWrite(() => this.db.query("INSERT INTO review_comment_packages(id,task_id,task_created,body) VALUES(?,?,?,?)").run(packet.id, task.id, task.created, encodedPacket));
|
|
1124
|
+
return this.update(task.id, "user", `person review comments sent (${selected.length})`, {}, undefined, {}, task.history.length);
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
reviewCommentPackages(task: number, created: number): ReviewCommentPackageView[] {
|
|
1128
|
+
const rows = this.db.query("SELECT id,CASE WHEN length(CAST(body AS BLOB))<=32768 THEN body END AS body FROM review_comment_packages WHERE task_id=? AND task_created=? ORDER BY id LIMIT 65").all(task, created) as { id: string; body: string | null }[];
|
|
1129
|
+
if (rows.length > 64) throw new Error("review comment package storage limit invalid; restore a trusted hub.db backup before retrying");
|
|
1130
|
+
return rows.map(row => {
|
|
1131
|
+
try {
|
|
1132
|
+
if (typeof row.body !== "string") throw new Error();
|
|
1133
|
+
const packet = JSON.parse(row.body) as ReviewCommentPackage;
|
|
1134
|
+
if (!packet || packet.id !== row.id || packet.task !== task || packet.taskCreated !== created || !Array.isArray(packet.ids) || packet.ids.length < 1 || packet.ids.length > REVIEW_COMMENT_LIMIT) throw new Error();
|
|
1135
|
+
const ids = reviewCommentIds(packet.ids);
|
|
1136
|
+
if (packet.visibleIds !== undefined && (reviewCommentIds(packet.visibleIds).some(id => !ids.includes(id)))) throw new Error();
|
|
1137
|
+
const delivery = packet.delivery ?? "unconfirmed";
|
|
1138
|
+
if (delivery !== "queued" && delivery !== "unconfirmed") throw new Error();
|
|
1139
|
+
return { id: row.id, count: packet.ids.length, delivery: packet.body && packet.envelopeId && packet.trace && packet.ts !== undefined && packet.publicationEvidenceVersion === 1 ? delivery : "unavailable" };
|
|
1140
|
+
} catch { throw new Error("review comment package storage invalid; stop the hub and restore a trusted hub.db backup, then restart and retry"); }
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
reviewCommentPackage(id: string): ReviewCommentPackage | undefined {
|
|
1144
|
+
const row = this.db.query("SELECT CASE WHEN length(CAST(body AS BLOB))<=32768 THEN body END AS body FROM review_comment_packages WHERE id=?").get(id) as { body: string | null } | null;
|
|
1145
|
+
if (!row) return undefined;
|
|
1146
|
+
try {
|
|
1147
|
+
if (typeof row.body !== "string") throw new Error();
|
|
1148
|
+
const packet = JSON.parse(row.body) as ReviewCommentPackage;
|
|
1149
|
+
if (!packet || packet.id !== id || !Number.isSafeInteger(packet.task) || !Number.isSafeInteger(packet.taskCreated) || !Array.isArray(packet.ids)
|
|
1150
|
+
|| packet.ids.length < 1 || packet.ids.length > REVIEW_COMMENT_LIMIT || reviewCommentIds(packet.ids).length !== packet.ids.length
|
|
1151
|
+
|| typeof packet.owner !== "string" || typeof packet.head !== "string" || typeof packet.at !== "string"
|
|
1152
|
+
|| packet.delivery !== undefined && packet.delivery !== "queued" && packet.delivery !== "unconfirmed"
|
|
1153
|
+
|| packet.publicationEvidenceVersion !== undefined && packet.publicationEvidenceVersion !== 1
|
|
1154
|
+
|| packet.visibleIds !== undefined && (reviewCommentIds(packet.visibleIds).some(value => !packet.ids.includes(value)))
|
|
1155
|
+
|| packet.body !== undefined && (typeof packet.body !== "string" || Buffer.byteLength(packet.body, "utf8") > 12000)
|
|
1156
|
+
|| packet.envelopeId !== undefined && !/^[a-f0-9-]{36}$/.test(packet.envelopeId)
|
|
1157
|
+
|| packet.trace !== undefined && !/^[a-f0-9-]{36}$/.test(packet.trace)
|
|
1158
|
+
|| packet.ts !== undefined && (!Number.isSafeInteger(packet.ts) || packet.ts < 0)) throw new Error();
|
|
1159
|
+
return packet;
|
|
1160
|
+
} catch { throw new Error("review comment package storage invalid; stop the hub and restore a trusted hub.db backup, then restart and retry"); }
|
|
1161
|
+
}
|
|
1162
|
+
markReviewCommentPackageQueued(id: string): void {
|
|
1163
|
+
this.taskWrite(() => {
|
|
1164
|
+
const packet = this.reviewCommentPackage(id);
|
|
1165
|
+
if (!packet || !packet.body || !packet.envelopeId || !packet.trace || packet.ts === undefined) throw new Error("review comment package is unavailable for retry; inspect the task review before sending a new comment");
|
|
1166
|
+
return this.db.query("UPDATE review_comment_packages SET body=? WHERE id=?").run(JSON.stringify({ ...packet, delivery: "queued" }), id);
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
hasOpenReviewCommentsFor(owner: string, taskId?: number): boolean {
|
|
1170
|
+
const taskScope = taskId === undefined ? "" : " AND task.id=?", packetScope = taskId === undefined ? "" : " AND packet.task_id=?";
|
|
1171
|
+
const currentOwner = this.db.query(`SELECT EXISTS(SELECT 1 FROM review_comments AS comment JOIN tasks AS task ON task.id=comment.task_id AND task.created=comment.task_created
|
|
1172
|
+
WHERE task.owner=? AND comment.lifecycle='sent'${taskScope}) AS present`).get(...(taskId === undefined ? [owner] : [owner, taskId])) as { present: number };
|
|
1173
|
+
if (currentOwner.present === 1) return true;
|
|
1174
|
+
const savedOwner = this.db.query(`WITH candidates AS (
|
|
1175
|
+
SELECT packet.id,packet.task_id,packet.task_created FROM review_comment_packages AS packet
|
|
1176
|
+
WHERE CASE WHEN json_valid(packet.body)=1 THEN json_extract(packet.body,'$.owner') END=?${packetScope}
|
|
1177
|
+
AND EXISTS(SELECT 1 FROM json_each(packet.body,'$.ids') AS package_id
|
|
1178
|
+
JOIN review_comments AS comment ON comment.task_id=packet.task_id AND comment.task_created=packet.task_created AND comment.id=package_id.value
|
|
1179
|
+
WHERE comment.lifecycle='sent')
|
|
1180
|
+
LIMIT 501
|
|
1181
|
+
) SELECT COUNT(*) AS count FROM candidates`).get(...(taskId === undefined ? [owner] : [owner, taskId])) as { count: number };
|
|
1182
|
+
return savedOwner.count > 0;
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Durable task-scoped exclusion for sent person comments and anchored findings; resolved/dismissed comments no longer hold memory.
|
|
1186
|
+
* ponytail: anchored-finding scope is conservative until task close; upgrade to a persisted review-package epoch for precise closure. Scans cap at 500
|
|
1187
|
+
* tasks and 16 MiB aggregate history; corrupt or over-limit state fails closed. A stamp over local total_changes and cross-connection
|
|
1188
|
+
* data_version makes repeated progress reads O(1) until the board changes.
|
|
1189
|
+
*/
|
|
1190
|
+
hasReviewObserverScope(peer: string, taskId: number): boolean {
|
|
1191
|
+
try {
|
|
1192
|
+
const dataVersion = this.db.query("PRAGMA data_version").get() as { data_version: number };
|
|
1193
|
+
const totalChanges = this.db.query("SELECT total_changes() AS changes").get() as { changes: number };
|
|
1194
|
+
const stamp = `${dataVersion.data_version}:${totalChanges.changes}`;
|
|
1195
|
+
if (stamp !== this.reviewObserverCacheStamp) { this.reviewObserverScopeCache.clear(); this.reviewObserverCacheStamp = stamp; }
|
|
1196
|
+
const cacheKey = JSON.stringify([peer, taskId]), cached = this.reviewObserverScopeCache.get(cacheKey);
|
|
1197
|
+
if (cached !== undefined) return cached;
|
|
1198
|
+
const scope = " AND id=?";
|
|
1199
|
+
const params = [peer, peer, taskId];
|
|
1200
|
+
const counts = this.db.query(`WITH candidate AS (
|
|
1201
|
+
SELECT history FROM tasks WHERE state NOT IN ('approved','cancelled') AND (owner=? OR reviewer=?)${scope} LIMIT 501
|
|
1202
|
+
)
|
|
1203
|
+
SELECT COUNT(*) AS tasks, COALESCE(SUM(length(CAST(history AS BLOB))),0) AS bytes,
|
|
1204
|
+
SUM(CASE WHEN json_valid(history)<>1 THEN 1 ELSE 0 END) AS invalid FROM candidate`).get(...params) as { tasks: number; bytes: number; invalid: number };
|
|
1205
|
+
let scoped = counts.tasks > 500 || counts.bytes > 16 * 1024 * 1024 || counts.invalid > 0;
|
|
1206
|
+
if (!scoped) {
|
|
1207
|
+
const comments = this.hasOpenReviewCommentsFor(peer, taskId);
|
|
1208
|
+
const reviewerCommentScope = this.db.query(`WITH candidate AS (
|
|
1209
|
+
SELECT id,created FROM tasks WHERE state='in_review' AND reviewer=?${scope} LIMIT 501
|
|
1210
|
+
) SELECT EXISTS(SELECT 1 FROM candidate JOIN review_comments AS comment ON comment.task_id=candidate.id AND comment.task_created=candidate.created
|
|
1211
|
+
WHERE comment.lifecycle='sent') AS present`).get(peer, taskId) as { present: number };
|
|
1212
|
+
const findings = this.db.query(`WITH candidate AS (
|
|
1213
|
+
SELECT id,history FROM tasks WHERE state NOT IN ('approved','cancelled') AND (owner=? OR reviewer=?)${scope} LIMIT 501
|
|
1214
|
+
)
|
|
1215
|
+
SELECT EXISTS(SELECT 1 FROM candidate, json_each(candidate.history) AS history,
|
|
1216
|
+
json_each(json_extract(history.value,'$.reviewFindings.items')) AS item
|
|
1217
|
+
WHERE json_type(history.value,'$.reviewFindings.items')='array'
|
|
1218
|
+
AND json_type(item.value,'$.anchor')='object') AS present`).get(...params) as { present: number };
|
|
1219
|
+
scoped = comments || reviewerCommentScope.present === 1 || findings.present === 1;
|
|
1220
|
+
}
|
|
1221
|
+
this.reviewObserverScopeCache.set(cacheKey, scoped);
|
|
1222
|
+
if (this.reviewObserverScopeCache.size > 512) this.reviewObserverScopeCache.delete(this.reviewObserverScopeCache.keys().next().value as string);
|
|
1223
|
+
return scoped;
|
|
1224
|
+
} catch { return true; }
|
|
1225
|
+
}
|
|
1226
|
+
reviewCommentResolution(task: Task, ids: readonly string[]): string[] {
|
|
1227
|
+
const parsed = reviewCommentIds(ids), records = this.reviewComments(task.id, task.created);
|
|
1228
|
+
return parsed.flatMap(id => {
|
|
1229
|
+
const record = records.find(row => row.thread.id === id);
|
|
1230
|
+
if (!record || record.lifecycle === "draft") throw new Error("only sent comments can be resolved; refresh the task review and use sent comment ids");
|
|
1231
|
+
return record.lifecycle === "sent" ? [id] : []; // Dismissal is terminal and can never be reopened by the owner.
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
hasReviewCommentsState(): boolean {
|
|
1235
|
+
return (this.db.query(`SELECT EXISTS(SELECT 1 FROM review_comments) OR EXISTS(SELECT 1 FROM review_comment_packages)
|
|
1236
|
+
OR EXISTS(SELECT 1 FROM tasks AS task,json_each(task.history) AS history,json_each(json_extract(history.value,'$.reviewFindings.items')) AS finding
|
|
1237
|
+
WHERE json_type(finding.value,'$.anchor')='object') AS present`).get() as { present: number }).present === 1;
|
|
1238
|
+
}
|
|
1239
|
+
reviewCommentsIntegrity(): { comments: number; packages: number; digest: string } {
|
|
1240
|
+
return this.db.transaction(() => {
|
|
1241
|
+
const digest = createHash("sha256").update("agent-hub.review-comments/v1\n"); let comments = 0, packages = 0;
|
|
1242
|
+
for (const row of this.db.query("SELECT DISTINCT task_id,task_created FROM review_comments ORDER BY task_id,task_created").iterate() as Iterable<{ task_id: number; task_created: number }>) for (const record of this.reviewComments(row.task_id, row.task_created)) { digest.update(JSON.stringify(record)).update("\n"); comments++; }
|
|
1243
|
+
for (const row of this.db.query("SELECT id,task_id,task_created,body FROM review_comment_packages ORDER BY id").iterate()) { digest.update(JSON.stringify(row)).update("\n"); packages++; }
|
|
1244
|
+
return { comments, packages, digest: digest.digest("hex") };
|
|
1245
|
+
})();
|
|
1246
|
+
}
|
|
1247
|
+
|
|
915
1248
|
hasResearchState(): boolean {
|
|
916
1249
|
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_campaigns) OR EXISTS(SELECT 1 FROM research_units) OR EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) OR EXISTS(SELECT 1 FROM research_runner_receipts) AS present").get() as { present: number }).present === 1;
|
|
917
1250
|
}
|
|
@@ -1421,7 +1754,7 @@ export class Board {
|
|
|
1421
1754
|
}
|
|
1422
1755
|
plannerWip(peer: string, except?: number): number {
|
|
1423
1756
|
const row = this.db.query(`SELECT COUNT(*) AS n FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND (? IS NULL OR id != ?) AND
|
|
1424
|
-
(owner = ? OR (owner IS NULL AND state = 'proposed' AND reserved = ? AND
|
|
1757
|
+
(owner = ? OR (owner IS NULL AND state = 'proposed' AND reserved = ? AND needs_person = 0 AND
|
|
1425
1758
|
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 };
|
|
1426
1759
|
return row.n;
|
|
1427
1760
|
}
|
|
@@ -1435,7 +1768,7 @@ export class Board {
|
|
|
1435
1768
|
const rows = this.db.query(`SELECT peer, kind, COUNT(*) AS n FROM (
|
|
1436
1769
|
SELECT owner AS peer, CASE WHEN state = 'in_review' THEN 'reviewWait' ELSE 'active' END AS kind FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND owner IS NOT NULL
|
|
1437
1770
|
UNION ALL
|
|
1438
|
-
SELECT reserved AS peer, 'reserved' AS kind FROM tasks WHERE state = 'proposed' AND owner IS NULL AND reserved IS NOT NULL AND
|
|
1771
|
+
SELECT reserved AS peer, 'reserved' AS kind FROM tasks WHERE state = 'proposed' AND owner IS NULL AND reserved IS NOT NULL AND needs_person = 0 AND
|
|
1439
1772
|
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
|
|
1440
1773
|
) GROUP BY peer, kind`).all() as { peer: string; kind: "active" | "reviewWait" | "reserved"; n: number }[];
|
|
1441
1774
|
const result: Record<string, { active: number; reviewWait: number; reserved: number }> = {};
|
|
@@ -1521,6 +1854,11 @@ export class Board {
|
|
|
1521
1854
|
}
|
|
1522
1855
|
|
|
1523
1856
|
/** Tasks per state, without parsing a single row: `status.json` is rewritten on every bus event and the board only grows. */
|
|
1857
|
+
/** #1176: ids of open cards only the person can run, for status and the board (ids only, never text). Backlog first, then the rest, by id. */
|
|
1858
|
+
personRunIds(): number[] {
|
|
1859
|
+
return (this.db.query("SELECT id FROM tasks WHERE needs_person = 1 AND state NOT IN ('approved', 'cancelled') ORDER BY id").all() as { id: number }[]).map(row => row.id);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1524
1862
|
counts(): Record<string, number> {
|
|
1525
1863
|
const rows = this.db.query("SELECT state, COUNT(*) AS n FROM tasks GROUP BY state").all() as { state: string; n: number }[];
|
|
1526
1864
|
return Object.fromEntries(rows.map((r) => [r.state, r.n]));
|
|
@@ -1528,7 +1866,8 @@ export class Board {
|
|
|
1528
1866
|
|
|
1529
1867
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
1530
1868
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
1531
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewedHead" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | "specReport" | "specRefBinding" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
1869
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewedHead" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | "specReport" | "specRefBinding" | "resolvedReviewComments" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
1870
|
+
if (extra.resolvedReviewComments?.length && !this.db.inTransaction) return this.atomic(() => this.update(id, by, event, patch, note, extra, expectedFlowRevision, codeSymbolsAuthority));
|
|
1532
1871
|
const task = this.get(id);
|
|
1533
1872
|
if (!task) throw new Error(`no task #${id}`);
|
|
1534
1873
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -1579,6 +1918,9 @@ export class Board {
|
|
|
1579
1918
|
if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
|
|
1580
1919
|
}
|
|
1581
1920
|
if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
|
|
1921
|
+
const requestedReviewComments = extra.resolvedReviewComments === undefined ? undefined : reviewCommentIds(extra.resolvedReviewComments);
|
|
1922
|
+
if (requestedReviewComments?.length && (!["done", "done (checking)"].includes(event) || by !== task.owner || expectedFlowRevision === undefined)) throw new Error("comment resolution requires an admitted owner completion; use hub_task_done with sent comment ids");
|
|
1923
|
+
const resolvedReviewComments = requestedReviewComments?.length ? this.reviewCommentResolution(task, requestedReviewComments) : undefined;
|
|
1582
1924
|
const reviewMetadata = reviewerHistory(extra);
|
|
1583
1925
|
let reviewFindings: ReviewFindingSet | undefined;
|
|
1584
1926
|
if (extra.reviewFindings !== undefined) {
|
|
@@ -1612,8 +1954,13 @@ export class Board {
|
|
|
1612
1954
|
&& priorHead !== undefined && /^[a-f0-9]{40}$/.test(priorHead);
|
|
1613
1955
|
const previousHistory = backfill ? task.history.map((entry, index) => index === priorApproval && !entry.reviewedHead
|
|
1614
1956
|
? { ...entry, reviewedHead: entry.reviewFindings?.head ?? priorHead } : entry) : task.history;
|
|
1615
|
-
const history = [...previousHistory, { at: Date.now(), by, event, ...(returnPlanning ? { planning: returnPlanning } : {}), ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...specMetadata, ...(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.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
|
|
1957
|
+
const history = [...previousHistory, { at: Date.now(), by, event, ...(resolvedReviewComments?.length ? { resolvedReviewComments } : {}), ...(returnPlanning ? { planning: returnPlanning } : {}), ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...specMetadata, ...(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.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
|
|
1616
1958
|
this.taskWrite(() => {
|
|
1959
|
+
if (resolvedReviewComments?.length) for (const record of this.reviewComments(task.id, task.created)) {
|
|
1960
|
+
if (!resolvedReviewComments.includes(record.thread.id) || record.lifecycle !== "sent") continue;
|
|
1961
|
+
const resolved: ReviewCommentRecord = { ...record, lifecycle: "resolved", revision: record.revision + 1 };
|
|
1962
|
+
this.db.query("UPDATE review_comments SET body=?,lifecycle=?,revision=? WHERE task_id=? AND task_created=? AND id=?").run(JSON.stringify(resolved), resolved.lifecycle, resolved.revision, task.id, task.created, record.thread.id);
|
|
1963
|
+
}
|
|
1617
1964
|
const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ?, planning_revision = ? WHERE id = ?")
|
|
1618
1965
|
.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(), planner, returnPlanning?.revision ?? task.planningRevision ?? 0, id);
|
|
1619
1966
|
if (result.changes) {
|
|
@@ -1636,10 +1983,10 @@ export class Board {
|
|
|
1636
1983
|
const next = { ...task, ...patch, sdlc: receipt ?? task.sdlc };
|
|
1637
1984
|
if (next.plannedStart != null && next.due != null && next.plannedStart > next.due) throw new Error("planned start must not follow due");
|
|
1638
1985
|
if (next.release != null && !this.release(next.release)) throw new Error("no such release");
|
|
1639
|
-
const fields = Object.keys(patch) as (keyof PlanningFields)[], previous = Object.fromEntries(fields.map(key => [key, key === "sdlc" ? { repo: task.sdlc?.repo ?? null, issue: task.sdlc?.issue ?? null } : task[key] ?? (key === "pinned" ? false : key === "deps" ? [] : null)])) as PlanningFields;
|
|
1986
|
+
const fields = Object.keys(patch) as (keyof PlanningFields)[], previous = Object.fromEntries(fields.map(key => [key, key === "sdlc" ? { repo: task.sdlc?.repo ?? null, issue: task.sdlc?.issue ?? null } : task[key] ?? (key === "pinned" || key === "needsPerson" ? false : key === "deps" ? [] : null)])) as PlanningFields;
|
|
1640
1987
|
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 } : {}) };
|
|
1641
|
-
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=?, sdlc=? WHERE id=? AND planning_revision=?`)
|
|
1642
|
-
.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, next.sdlc === undefined ? null : JSON.stringify(next.sdlc), id, revision));
|
|
1988
|
+
const result = this.taskWrite(() => this.db.query(`UPDATE tasks SET rank=?, priority=?, "release"=?, planned_start=?, due=?, pinned=?, needs_person=?, deps=?, reserved=?, planning_revision=planning_revision+1, history=?, updated=?, sdlc=? WHERE id=? AND planning_revision=?`)
|
|
1989
|
+
.run(next.rank ?? null, next.priority ?? null, next.release ?? null, next.plannedStart ?? null, next.due ?? null, next.pinned ? 1 : 0, next.needsPerson ? 1 : 0, JSON.stringify(next.deps ?? []), next.reserved ?? null, JSON.stringify([...task.history, entry]), now, next.sdlc === undefined ? null : JSON.stringify(next.sdlc), id, revision));
|
|
1643
1990
|
if (result.changes !== 1) throw new Error("planning revision changed");
|
|
1644
1991
|
const updated = this.get(id)!; this.changed(updated, entry); return updated;
|
|
1645
1992
|
}
|
|
@@ -1692,6 +2039,8 @@ export class Board {
|
|
|
1692
2039
|
// #334: the hub's own intake split is only ever for a draft. An approval clears the mark without moving the planning revision, so the revision alone cannot tell the
|
|
1693
2040
|
// person approved the card while the model answered; checked here, in the same transaction as the write, nothing can slip between this and the children.
|
|
1694
2041
|
if (requireDraft && !parent.draft) throw new Error("split changed or invalid");
|
|
2042
|
+
// #1176: an agent's split of a person-run card, checked in the transaction so nothing slips in between the planner's check and the write.
|
|
2043
|
+
if (by !== USER && parent.needsPerson) throw new Error("a person-run card is the person's to split");
|
|
1695
2044
|
const children: Task[] = [];
|
|
1696
2045
|
for (const part of parts) {
|
|
1697
2046
|
const inherited = part.afterParts?.length ? (part.deps ?? []).filter(dep => !parent.deps?.includes(dep)) : part.deps ?? [];
|
|
@@ -1870,6 +2219,8 @@ function parse(row: Record<string, unknown>): Task {
|
|
|
1870
2219
|
if (out.planning_revision) out.planningRevision = out.planning_revision;
|
|
1871
2220
|
delete out.planning_revision;
|
|
1872
2221
|
if (out.pinned) out.pinned = true; else delete out.pinned;
|
|
2222
|
+
if (out.needs_person) out.needsPerson = true;
|
|
2223
|
+
delete out.needs_person;
|
|
1873
2224
|
if (out.draft) out.draft = true; else delete out.draft;
|
|
1874
2225
|
for (const key of ["rank", "priority", "release", "due"]) if (out[key] == null) delete out[key];
|
|
1875
2226
|
out.planner = row.planner ?? null;
|