@staix/agent-hub 0.23.1 → 0.25.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 +24 -0
- package/README.md +32 -5
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +16 -2
- package/docs/agent-notes/tasks.md +9 -2
- package/docs/agent-notes/tests.md +3 -0
- package/docs/dogfood.md +16 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +198 -5
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +19 -4
- package/docs/security.md +225 -8
- package/docs/specs/2026-09-19-agent-hub-design.md +54 -1
- package/docs/specs/2026-09-19-multi-project-design.md +12 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
- package/docs/verification/2026-10-31-0.24.0.md +63 -0
- package/docs/verification/2026-10-31-claude-implementer-2.1.316.md +65 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +31 -15
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5120 -4710
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/native-cli-claude-setup.ts +4 -0
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry.ts +1 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/autonomy-homes.ts +33 -0
- package/src/cli/autonomy-offline.ts +75 -0
- package/src/cli/autonomy-projects.ts +103 -0
- package/src/cli/autonomy.ts +44 -0
- package/src/cli/claude-homes.ts +123 -0
- package/src/cli/claude-profile-hook.ts +130 -0
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +8 -4
- package/src/cli/identity.ts +8 -1
- package/src/cli/main.ts +174 -33
- package/src/cli/output.ts +8 -5
- package/src/cli/schedule-output.ts +8 -4
- package/src/cli/setup-homes.ts +46 -0
- package/src/cli/setup.ts +1 -1
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +1 -0
- package/src/hub/action-class.ts +203 -14
- package/src/hub/action-enforce.ts +8 -6
- package/src/hub/action-policy.ts +95 -9
- package/src/hub/autonomy-consent.ts +173 -0
- package/src/hub/autonomy-defaults.ts +58 -0
- package/src/hub/autonomy.ts +232 -0
- package/src/hub/board.ts +871 -2
- package/src/hub/channel-project.ts +61 -0
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/confirmation-matrix.ts +202 -0
- package/src/hub/control-client.ts +10 -1
- package/src/hub/daemon.ts +546 -27
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +12 -0
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/manager.ts +64 -57
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project.ts +14 -5
- package/src/hub/provenance.ts +30 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +445 -0
- package/src/hub/restart.ts +26 -0
- package/src/hub/schedules.ts +210 -9
- package/src/hub/session-identity.ts +49 -0
- package/src/hub/settings.ts +105 -8
- package/src/hub/task-repository.ts +11 -6
- package/src/hub/tasks.ts +894 -22
- package/src/hub/ui.ts +15 -6
- package/src/memory/capture.ts +2 -2
- package/src/ui/index.html +5 -3
- package/src/ui/office-space.ts +118 -0
- package/src/ui/plugin.ts +293 -0
- package/templates/AGENTS.block.md +1 -0
- package/templates/presets/autonomous.json +161 -0
- package/templates/presets/confirmation-matrix.json +943 -0
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +4 -4
- package/templates/profiles/writing.json +1 -1
- package/templates/roles/research/analyst.md +1 -0
- package/templates/roles/research/fact-checker.md +1 -0
- package/templates/roles/research/research-lead.md +1 -0
- package/templates/roles/research/researcher.md +1 -0
- package/templates/roles/research/statistician.md +1 -0
package/src/hub/board.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { canonicalJson } from "./packs.ts";
|
|
2
|
+
import { parseComparisonPlan, comparisonPlanHash, evaluatorIdentityHash, parseExperimentProtocol, protocolHash, parseExperimentAttempt, parseExperimentEvent, parseExperimentHostEvidence, experimentStatus, projectExperimentWithObservations,
|
|
3
|
+
type ComparisonPlanV1, type ExperimentProtocolRecord, type ExperimentAttemptRecord, type ExperimentEventRecord, type ExperimentWriteFence, type DeclaredExperimentEventInput, type ExperimentHostEvidence, type ExperimentStatus } from "./research-experiments.ts";
|
|
1
4
|
import type { InvocationRow } from "./invocations.ts";
|
|
2
5
|
import { HOST_CODE_SYMBOLS, parseTaskCodeSymbols, type TaskCodeSymbols } from "./task-code-symbols.ts";
|
|
3
6
|
import { createHash } from "node:crypto";
|
|
@@ -7,13 +10,114 @@ import type { ReviewerPoolReason } from "./reviewer-pool.ts";
|
|
|
7
10
|
import type { GitHubSource } from "./github-import.ts";
|
|
8
11
|
import { Database } from "bun:sqlite";
|
|
9
12
|
import { planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
10
|
-
import { HUB, type PeerId } from "./envelope.ts";
|
|
13
|
+
import { HUB, USER, type PeerId } from "./envelope.ts";
|
|
11
14
|
import type { SweepRecord } from "./task-sweep.ts";
|
|
12
15
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
13
16
|
import type { SdlcReceipt } from "./sdlc.ts";
|
|
14
17
|
import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
|
|
15
18
|
import { parseDeliverableRecords, type DeliverableRecord } from "./deliverables.ts";
|
|
16
19
|
import type { CostPlan, ReviewCost } from "./cost-governance.ts";
|
|
20
|
+
import { campaignHash, parseResearchCampaign, parseResearchHandoff, type ResearchCampaignContract, type ResearchCampaignStatus, type ResearchUnit, type ResearchHandoffV1, type ResearchHandoffRecord } from "./research-campaign.ts";
|
|
21
|
+
|
|
22
|
+
export interface ResearchCampaignRecord {
|
|
23
|
+
id: string;
|
|
24
|
+
project: string;
|
|
25
|
+
parentTaskId: number;
|
|
26
|
+
revision: number;
|
|
27
|
+
status: ResearchCampaignStatus;
|
|
28
|
+
contract: ResearchCampaignContract;
|
|
29
|
+
hash: string;
|
|
30
|
+
private: boolean;
|
|
31
|
+
createdBy: PeerId;
|
|
32
|
+
createdAt: number;
|
|
33
|
+
approvedBy?: PeerId;
|
|
34
|
+
approvedAt?: number;
|
|
35
|
+
closedBy?: PeerId;
|
|
36
|
+
closedAt?: number;
|
|
37
|
+
closedReason?: string;
|
|
38
|
+
dispatchVersion?: 1;
|
|
39
|
+
eligibleOwners?: string[];
|
|
40
|
+
}
|
|
41
|
+
export interface ResearchUnitBinding {
|
|
42
|
+
campaignId: string;
|
|
43
|
+
revision: number;
|
|
44
|
+
unitId: string;
|
|
45
|
+
taskId: number;
|
|
46
|
+
round: number;
|
|
47
|
+
purpose: ResearchUnit["purpose"];
|
|
48
|
+
adopted: boolean;
|
|
49
|
+
}
|
|
50
|
+
export interface PreparedResearchUnit {
|
|
51
|
+
unitId: string;
|
|
52
|
+
create?: Parameters<Board["propose"]>[1];
|
|
53
|
+
adopt?: { taskId: number; flowRevision: number };
|
|
54
|
+
reuse?: { taskId: number; flowRevision: number };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ResearchCampaignRow {
|
|
58
|
+
id: string; project: string; parent_task_id: number; revision: number; status: string; body: string | null; body_sha256: string; private: number;
|
|
59
|
+
created_by: string; created_at: number; approved_by: string | null; approved_at: number | null;
|
|
60
|
+
closed_by: string | null; closed_at: number | null; closed_reason: string | null;
|
|
61
|
+
}
|
|
62
|
+
interface ResearchHandoffRow { campaign_id: string; unit_id: string; seq: number; revision: number; task_id: number; body: string | null; by_peer: string; at: number; private: number }
|
|
63
|
+
interface ResearchUnitRow { campaign_id: string; revision: number; unit_id: string; task_id: number; round: number; purpose: string; adopted: number }
|
|
64
|
+
const RESEARCH_CAMPAIGN_SELECT = "SELECT id,project,parent_task_id,revision,status,CASE WHEN length(CAST(body AS BLOB)) <= 65536 THEN body END AS body,body_sha256,private,created_by,created_at,approved_by,approved_at,closed_by,closed_at,closed_reason FROM research_campaigns";
|
|
65
|
+
const researchFailure = (): Error => new Error("research campaign state is invalid, changed or corrupt");
|
|
66
|
+
const researchId = (value: unknown): value is string => typeof value === "string" && value.length === 15 && /^rc-[0-9a-f]{12}$/.test(value);
|
|
67
|
+
const researchInteger = (value: unknown, minimum = 1): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
|
|
68
|
+
const researchText = (value: unknown, max = 512): value is string => typeof value === "string" && value.length > 0 && value.length <= max && Buffer.byteLength(value, "utf8") <= max && Buffer.from(value, "utf8").toString("utf8") === value;
|
|
69
|
+
function checkedResearchCampaign(record: ResearchCampaignRecord): ResearchCampaignRecord {
|
|
70
|
+
if (!researchId(record.id) || !researchText(record.project) || !researchInteger(record.parentTaskId) || !researchInteger(record.revision) ||
|
|
71
|
+
!["draft", "approved", "superseded", "closed"].includes(record.status) || typeof record.private !== "boolean" || !researchText(record.createdBy) || !researchInteger(record.createdAt, 0)) throw researchFailure();
|
|
72
|
+
const contract = parseResearchCampaign(record.contract), hash = campaignHash(contract);
|
|
73
|
+
if (record.hash !== hash || (record.approvedBy === undefined) !== (record.approvedAt === undefined) ||
|
|
74
|
+
record.approvedBy !== undefined && (!researchText(record.approvedBy) || !researchInteger(record.approvedAt, 0)) ||
|
|
75
|
+
(record.closedBy === undefined) !== (record.closedAt === undefined) || (record.closedBy === undefined) !== (record.closedReason === undefined) ||
|
|
76
|
+
record.closedBy !== undefined && (!researchText(record.closedBy) || !researchInteger(record.closedAt, 0) || !researchText(record.closedReason, 4096)) ||
|
|
77
|
+
["approved", "superseded"].includes(record.status) && record.approvedBy === undefined || record.status === "closed" && record.closedBy === undefined ||
|
|
78
|
+
record.status !== "closed" && record.closedBy !== undefined || record.status === "draft" && record.approvedBy !== undefined) throw researchFailure();
|
|
79
|
+
return { ...record, contract, hash };
|
|
80
|
+
}
|
|
81
|
+
function researchCampaignFromRow(row: ResearchCampaignRow): ResearchCampaignRecord {
|
|
82
|
+
try {
|
|
83
|
+
if (typeof row.body !== "string" || ![0, 1].includes(row.private)) throw researchFailure();
|
|
84
|
+
return checkedResearchCampaign({ id: row.id, project: row.project, parentTaskId: row.parent_task_id, revision: row.revision, status: row.status as ResearchCampaignStatus,
|
|
85
|
+
contract: JSON.parse(row.body) as ResearchCampaignContract, hash: row.body_sha256, private: row.private === 1, createdBy: row.created_by, createdAt: row.created_at,
|
|
86
|
+
...(row.approved_by !== null ? { approvedBy: row.approved_by } : {}), ...(row.approved_at !== null ? { approvedAt: row.approved_at } : {}),
|
|
87
|
+
...(row.closed_by !== null ? { closedBy: row.closed_by } : {}), ...(row.closed_at !== null ? { closedAt: row.closed_at } : {}), ...(row.closed_reason !== null ? { closedReason: row.closed_reason } : {}) });
|
|
88
|
+
} catch { throw researchFailure(); }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ExperimentComparisonPlanRecord {
|
|
92
|
+
hash: string;
|
|
93
|
+
project: string;
|
|
94
|
+
recordedForTaskId: number;
|
|
95
|
+
plan: ComparisonPlanV1;
|
|
96
|
+
by: PeerId;
|
|
97
|
+
at: number;
|
|
98
|
+
private: boolean;
|
|
99
|
+
}
|
|
100
|
+
export interface ExperimentProtocolApproval { protocolHash: string; evaluatorHash: string; approvedBy: PeerId; at: number }
|
|
101
|
+
export type ExperimentProtocolContext = { state: "unknown" } | { state: "predeclared" } | { state: "post-hoc"; priorFinal: { attemptId: string; endSeq: number } };
|
|
102
|
+
interface ExperimentComparisonPlanRow { row_id?: number; hash: string; project: string; campaign_id: string; campaign_revision: number; task_id: number; body: string | null; by_peer: string; at: number; private: number }
|
|
103
|
+
interface ExperimentProtocolApprovalRow { protocol_hash: string; evaluator_hash: string; approved_by: string; at: number }
|
|
104
|
+
interface ExperimentProtocolContextRow { protocol_hash: string; prior_attempt_id: string | null; prior_end_seq: number | null; context_sha256: string }
|
|
105
|
+
const EXPERIMENT_PLAN_SELECT = "SELECT rowid AS row_id,hash,project,campaign_id,campaign_revision,task_id,CASE WHEN length(CAST(body AS BLOB))<=65536 THEN body END AS body,by_peer,at,private FROM research_plans";
|
|
106
|
+
|
|
107
|
+
interface ExperimentProtocolRow { hash: string; project: string; campaign_id: string; campaign_revision: number; task_id: number; body: string | null; by_peer: string; at: number; private: number }
|
|
108
|
+
interface ExperimentAttemptRow { row_id?: number; attempt_id: string; project: string; campaign_id: string; campaign_revision: number; protocol_hash: string; task_id: number; idempotency_key: string; body: string | null; body_sha256: string; by_peer: string; at: number; private: number }
|
|
109
|
+
interface ExperimentEventRow { attempt_id: string; project: string; seq: number; idempotency_key: string; kind: string; body: string | null; body_sha256: string; by_peer: string; at: number; private: number }
|
|
110
|
+
interface ExperimentObservationRow { attempt_id: string; event_seq: number; body: string | null; body_sha256: string }
|
|
111
|
+
const EXPERIMENT_PROTOCOL_SELECT = "SELECT hash,project,campaign_id,campaign_revision,task_id,CASE WHEN length(CAST(body AS BLOB))<=65536 THEN body END AS body,by_peer,at,private FROM research_protocols";
|
|
112
|
+
const EXPERIMENT_ATTEMPT_SELECT = "SELECT rowid AS row_id,attempt_id,project,campaign_id,campaign_revision,protocol_hash,task_id,idempotency_key,CASE WHEN length(CAST(body AS BLOB))<=65536 THEN body END AS body,body_sha256,by_peer,at,private FROM research_attempts";
|
|
113
|
+
const EXPERIMENT_EVENT_SELECT = "SELECT attempt_id,project,seq,idempotency_key,kind,CASE WHEN length(CAST(body AS BLOB))<=65536 THEN body END AS body,body_sha256,by_peer,at,private FROM research_attempt_events";
|
|
114
|
+
const experimentFailure = (): Error => new Error("experiment state is invalid, changed or corrupt");
|
|
115
|
+
const experimentHash = (value: unknown): string => createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
116
|
+
const experimentHashValid = (value: unknown): value is string => typeof value === "string" && value.length === 64 && /^[0-9a-f]{64}$/.test(value);
|
|
117
|
+
const experimentIdValid = (value: unknown): value is string => typeof value === "string" && /^ra-[0-9a-f]{12}$/.test(value);
|
|
118
|
+
const experimentPage = (after: number, limit: number): void => {
|
|
119
|
+
if (!researchInteger(after, 0) || !researchInteger(limit) || limit > 50) throw experimentFailure();
|
|
120
|
+
};
|
|
17
121
|
|
|
18
122
|
export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
|
|
19
123
|
export type TaskClass = (typeof CLASSES)[number];
|
|
@@ -174,6 +278,11 @@ function plannerMetadata(planner: unknown, reason?: PlannerReason): Pick<History
|
|
|
174
278
|
if (reason !== undefined && !["rotation", "named", "kept", "none"].includes(reason)) throw new Error("invalid planner reason");
|
|
175
279
|
return { planner: plannerValue(planner), ...(reason === undefined ? {} : { plannerReason: reason }) };
|
|
176
280
|
}
|
|
281
|
+
/** #604: what this project handed to another project, and the last state that project reported (agent-hub#1030 design 4). */
|
|
282
|
+
export interface HandoffOutboundRow { remoteProject: string; remoteTask: number; originPeer: string; localTask: number | null; title: string; state: string; pr: number | null; head: string | null; updatedAt: number }
|
|
283
|
+
const handoffOutboundFromRow = (row: Record<string, unknown>): HandoffOutboundRow => ({ remoteProject: String(row.remote_project), remoteTask: Number(row.remote_task), originPeer: String(row.origin_peer),
|
|
284
|
+
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) });
|
|
285
|
+
|
|
177
286
|
export interface Task extends PlanningFields {
|
|
178
287
|
offer?: TaskOffer;
|
|
179
288
|
cancelled?: TaskCancellation;
|
|
@@ -354,6 +463,761 @@ export class Board {
|
|
|
354
463
|
this.db.run("CREATE INDEX IF NOT EXISTS releases_planner_present ON releases(planner) WHERE planner IS NOT NULL");
|
|
355
464
|
if (!(this.db.query("PRAGMA table_info(releases)").all() as { name: string }[]).some(row => row.name === "train")) this.db.run("ALTER TABLE releases ADD COLUMN train TEXT");
|
|
356
465
|
|
|
466
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_campaigns (
|
|
467
|
+
id TEXT NOT NULL, project TEXT NOT NULL, parent_task_id INTEGER NOT NULL, revision INTEGER NOT NULL,
|
|
468
|
+
status TEXT NOT NULL, body TEXT NOT NULL, body_sha256 TEXT NOT NULL, private INTEGER NOT NULL,
|
|
469
|
+
created_by TEXT NOT NULL, created_at INTEGER NOT NULL, approved_by TEXT, approved_at INTEGER,
|
|
470
|
+
closed_by TEXT, closed_at INTEGER, closed_reason TEXT, PRIMARY KEY(id,revision))`);
|
|
471
|
+
this.db.run("CREATE UNIQUE INDEX IF NOT EXISTS research_campaigns_active_parent ON research_campaigns(project,parent_task_id) WHERE status='approved'");
|
|
472
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_units (
|
|
473
|
+
campaign_id TEXT NOT NULL, revision INTEGER NOT NULL, unit_id TEXT NOT NULL, task_id INTEGER NOT NULL,
|
|
474
|
+
round INTEGER NOT NULL, purpose TEXT NOT NULL, adopted INTEGER NOT NULL, PRIMARY KEY(campaign_id,revision,unit_id))`);
|
|
475
|
+
this.db.run("CREATE INDEX IF NOT EXISTS research_units_task ON research_units(task_id)");
|
|
476
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_dispatch (
|
|
477
|
+
campaign_id TEXT NOT NULL, revision INTEGER NOT NULL, version INTEGER NOT NULL, eligible_owners TEXT,
|
|
478
|
+
PRIMARY KEY(campaign_id,revision))`);
|
|
479
|
+
// #603: the owner whose queued offer a study hold made the bus discard, kept so approving the study offers the retained task again after a restart.
|
|
480
|
+
this.db.run("CREATE TABLE IF NOT EXISTS research_dropped_offers (task_id INTEGER PRIMARY KEY, owner TEXT NOT NULL)");
|
|
481
|
+
// #604 (agent-hub#1030): cross-project handoff. `task_handoff_origin` is the TARGET's record of where a task came from (one row per task it created for another project); `task_handoff_outbound`
|
|
482
|
+
// 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
|
|
483
|
+
// lacking the feature is refused at recovery while either holds a row.
|
|
484
|
+
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)");
|
|
485
|
+
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,
|
|
486
|
+
title TEXT NOT NULL, last_state TEXT NOT NULL, pr INTEGER, head TEXT, updated_at INTEGER NOT NULL, PRIMARY KEY(remote_project, remote_task))`);
|
|
487
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_handoffs (
|
|
488
|
+
campaign_id TEXT NOT NULL, unit_id TEXT NOT NULL, seq INTEGER NOT NULL, revision INTEGER NOT NULL,
|
|
489
|
+
task_id INTEGER NOT NULL, body TEXT NOT NULL, by_peer TEXT NOT NULL, at INTEGER NOT NULL, private INTEGER NOT NULL,
|
|
490
|
+
PRIMARY KEY(campaign_id,unit_id,seq))`);
|
|
491
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_protocols (
|
|
492
|
+
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,
|
|
493
|
+
by_peer TEXT NOT NULL, at INTEGER NOT NULL, private INTEGER NOT NULL)`);
|
|
494
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_attempts (
|
|
495
|
+
attempt_id TEXT PRIMARY KEY, project TEXT NOT NULL, campaign_id TEXT NOT NULL, campaign_revision INTEGER NOT NULL, protocol_hash TEXT NOT NULL, task_id INTEGER NOT NULL,
|
|
496
|
+
idempotency_key TEXT NOT NULL, body TEXT NOT NULL, body_sha256 TEXT NOT NULL, by_peer TEXT NOT NULL,
|
|
497
|
+
at INTEGER NOT NULL, private INTEGER NOT NULL, UNIQUE(project,idempotency_key))`);
|
|
498
|
+
this.db.run("CREATE INDEX IF NOT EXISTS research_attempts_campaign ON research_attempts(project,campaign_id)");
|
|
499
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_attempt_events (
|
|
500
|
+
attempt_id TEXT NOT NULL, project TEXT NOT NULL, seq INTEGER NOT NULL, idempotency_key TEXT NOT NULL,
|
|
501
|
+
kind TEXT NOT NULL, body TEXT NOT NULL, body_sha256 TEXT NOT NULL, by_peer TEXT NOT NULL,
|
|
502
|
+
at INTEGER NOT NULL, private INTEGER NOT NULL, PRIMARY KEY(attempt_id,seq), UNIQUE(project,idempotency_key))`);
|
|
503
|
+
this.db.run("CREATE UNIQUE INDEX IF NOT EXISTS research_attempt_one_end ON research_attempt_events(attempt_id) WHERE kind='end'");
|
|
504
|
+
this.db.run("CREATE INDEX IF NOT EXISTS research_attempt_private_event ON research_attempt_events(attempt_id) WHERE private=1");
|
|
505
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_attempt_observations (
|
|
506
|
+
attempt_id TEXT NOT NULL, event_seq INTEGER NOT NULL, body TEXT NOT NULL, body_sha256 TEXT NOT NULL,
|
|
507
|
+
PRIMARY KEY(attempt_id,event_seq))`);
|
|
508
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_plans (
|
|
509
|
+
hash TEXT PRIMARY KEY, project TEXT NOT NULL, campaign_id TEXT NOT NULL, campaign_revision INTEGER NOT NULL,
|
|
510
|
+
task_id INTEGER NOT NULL, body TEXT NOT NULL, by_peer TEXT NOT NULL, at INTEGER NOT NULL, private INTEGER NOT NULL)`);
|
|
511
|
+
this.db.run("CREATE INDEX IF NOT EXISTS research_plans_campaign ON research_plans(project,campaign_id)");
|
|
512
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_protocol_approvals (
|
|
513
|
+
protocol_hash TEXT PRIMARY KEY, evaluator_hash TEXT NOT NULL, approved_by TEXT NOT NULL, at INTEGER NOT NULL)`);
|
|
514
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_protocol_context (
|
|
515
|
+
protocol_hash TEXT PRIMARY KEY, prior_attempt_id TEXT, prior_end_seq INTEGER, context_sha256 TEXT NOT NULL)`);
|
|
516
|
+
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** #604: a handoff row exists in either direction, so a recovery target must know `task_handoff`. */
|
|
520
|
+
hasHandoffState(): boolean {
|
|
521
|
+
return (this.db.query("SELECT EXISTS(SELECT 1 FROM task_handoff_origin) OR EXISTS(SELECT 1 FROM task_handoff_outbound) AS present").get() as { present: number }).present === 1;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** TARGET side: where one of this project's tasks came from. The origin peer is a record, never a roster peer or an actor. */
|
|
525
|
+
handoffOrigin(taskId: number): { projectId: string; peer: string; originTask: number | null; at: number } | undefined {
|
|
526
|
+
const row = this.db.query("SELECT project_id, peer, origin_task, at FROM task_handoff_origin WHERE task_id=?").get(taskId) as { project_id: string; peer: string; origin_task: number | null; at: number } | null;
|
|
527
|
+
return row ? { projectId: row.project_id, peer: row.peer, originTask: row.origin_task, at: row.at } : undefined;
|
|
528
|
+
}
|
|
529
|
+
setHandoffOrigin(taskId: number, origin: { projectId: string; peer: string; originTask?: number }): void {
|
|
530
|
+
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());
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** ORIGIN side: what this project handed to another one. Keyed by the target project and its task id, so a repeated event updates one row. */
|
|
534
|
+
handoffOutbound(): HandoffOutboundRow[] {
|
|
535
|
+
return (this.db.query("SELECT remote_project, remote_task, origin_peer, local_task, title, last_state, pr, head, updated_at FROM task_handoff_outbound ORDER BY updated_at DESC, remote_project, remote_task LIMIT 200").all() as Record<string, unknown>[]).map(handoffOutboundFromRow);
|
|
536
|
+
}
|
|
537
|
+
handoffOutboundFor(remoteProject: string, remoteTask: number): HandoffOutboundRow | undefined {
|
|
538
|
+
const row = this.db.query("SELECT remote_project, remote_task, origin_peer, local_task, title, last_state, pr, head, updated_at FROM task_handoff_outbound WHERE remote_project=? AND remote_task=?").get(remoteProject, remoteTask) as Record<string, unknown> | null;
|
|
539
|
+
return row ? handoffOutboundFromRow(row) : undefined;
|
|
540
|
+
}
|
|
541
|
+
recordHandoffOutbound(row: { remoteProject: string; remoteTask: number; originPeer: string; localTask?: number; title: string; state: string; pr?: number; head?: string }): void {
|
|
542
|
+
this.db.query(`INSERT INTO task_handoff_outbound(remote_project, remote_task, origin_peer, local_task, title, last_state, pr, head, updated_at) VALUES(?,?,?,?,?,?,?,?,?)
|
|
543
|
+
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`)
|
|
544
|
+
.run(row.remoteProject, row.remoteTask, row.originPeer, row.localTask ?? null, row.title, row.state, row.pr ?? null, row.head ?? null, Date.now());
|
|
545
|
+
}
|
|
546
|
+
/** A digest over both tables, so a recovery that restores or loses a row is seen. */
|
|
547
|
+
handoffIntegrity(): { origins: number; outbound: number; digest: string } {
|
|
548
|
+
return this.db.transaction(() => {
|
|
549
|
+
const digest = createHash("sha256").update("agent-hub.handoff-integrity/v1\n");
|
|
550
|
+
let origins = 0, outbound = 0;
|
|
551
|
+
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()) { digest.update(JSON.stringify(row)).update("\n"); origins++; }
|
|
552
|
+
for (const row of this.db.query("SELECT remote_project, remote_task, origin_peer, local_task, title, last_state, pr, head, updated_at FROM task_handoff_outbound ORDER BY remote_project, remote_task").iterate()) { digest.update(JSON.stringify(row)).update("\n"); outbound++; }
|
|
553
|
+
return { origins, outbound, digest: digest.digest("hex") };
|
|
554
|
+
})();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** #601: the ids of a project's campaigns, oldest first, for `ahub study show` with no id. A read: no row is written. */
|
|
558
|
+
researchCampaignIds(project: string): string[] {
|
|
559
|
+
if (!researchText(project)) throw researchFailure();
|
|
560
|
+
return (this.db.query("SELECT DISTINCT id FROM research_campaigns WHERE project=? ORDER BY id").all(project) as { id: string }[]).map(row => row.id);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* One page of a project's campaign ids, in id order: at most `limit` (never more than 50, the spec's readback bound) strictly after the cursor `after`. `next` is the last id of a full page that
|
|
565
|
+
* has more behind it, to pass as the next `after`; absent when this is the last page. The query itself carries the bound, so a project with many drafts costs one page, not all of them.
|
|
566
|
+
*/
|
|
567
|
+
researchCampaignPage(project: string, after?: string, limit = 50): { ids: string[]; next?: string } {
|
|
568
|
+
if (!researchText(project) || after !== undefined && !researchId(after) || !Number.isSafeInteger(limit) || limit < 1) throw researchFailure();
|
|
569
|
+
const size = Math.min(limit, 50);
|
|
570
|
+
const rows = this.db.query("SELECT DISTINCT id FROM research_campaigns WHERE project=? AND id>? ORDER BY id LIMIT ?").all(project, after ?? "", size + 1) as { id: string }[];
|
|
571
|
+
const ids = rows.slice(0, size).map(row => row.id);
|
|
572
|
+
return rows.length > size ? { ids, next: ids[ids.length - 1]! } : { ids };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
researchCampaign(id: string, revision?: number): ResearchCampaignRecord | undefined {
|
|
576
|
+
if (!researchId(id) || revision !== undefined && !researchInteger(revision)) throw researchFailure();
|
|
577
|
+
const row = (revision === undefined
|
|
578
|
+
? this.db.query(`${RESEARCH_CAMPAIGN_SELECT} WHERE id=? ORDER BY revision DESC LIMIT 1`).get(id)
|
|
579
|
+
: this.db.query(`${RESEARCH_CAMPAIGN_SELECT} WHERE id=? AND revision=?`).get(id, revision)) as ResearchCampaignRow | null;
|
|
580
|
+
if (!row) return undefined;
|
|
581
|
+
const campaign = researchCampaignFromRow(row);
|
|
582
|
+
const dispatch = this.db.query("SELECT version,CASE WHEN length(CAST(eligible_owners AS BLOB))<=32768 OR eligible_owners IS NULL THEN eligible_owners ELSE '!' END AS eligible_owners FROM research_dispatch WHERE campaign_id=? AND revision=?")
|
|
583
|
+
.get(id, campaign.revision) as { version: number; eligible_owners: string | null } | null;
|
|
584
|
+
if (!dispatch) return campaign;
|
|
585
|
+
if (dispatch.version !== 1 || campaign.approvedBy === undefined) throw researchFailure();
|
|
586
|
+
let owners: string[] | undefined;
|
|
587
|
+
if (dispatch.eligible_owners !== null) {
|
|
588
|
+
try { owners = JSON.parse(dispatch.eligible_owners) as string[]; } catch { throw researchFailure(); }
|
|
589
|
+
if (!Array.isArray(owners) || owners.length < 1 || owners.length > 256 || owners.some(owner => !researchText(owner, 64) || !/^[a-z][a-z0-9_-]{0,63}$/.test(owner)) || new Set(owners).size !== owners.length) throw researchFailure();
|
|
590
|
+
}
|
|
591
|
+
if (!!campaign.contract.budget?.hardCap.length !== !!owners?.length) throw researchFailure();
|
|
592
|
+
return { ...campaign, dispatchVersion: 1, ...(owners ? { eligibleOwners: owners } : {}) };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private researchBindings(campaign: ResearchCampaignRecord): ResearchUnitBinding[] {
|
|
596
|
+
const rows = this.db.query("SELECT campaign_id,revision,unit_id,task_id,round,purpose,adopted FROM research_units WHERE campaign_id=? AND revision=? ORDER BY unit_id LIMIT 33").all(campaign.id, campaign.revision) as ResearchUnitRow[];
|
|
597
|
+
const expected = new Map(campaign.contract.units.map(unit => [unit.id, unit]));
|
|
598
|
+
if (rows.length > 32 || rows.length !== (campaign.approvedBy === undefined ? 0 : expected.size)) throw researchFailure();
|
|
599
|
+
const tasks = new Set<number>();
|
|
600
|
+
return rows.map(row => {
|
|
601
|
+
const unit = expected.get(row.unit_id);
|
|
602
|
+
if (!unit || row.campaign_id !== campaign.id || row.revision !== campaign.revision || !researchInteger(row.task_id) || tasks.has(row.task_id) ||
|
|
603
|
+
row.round !== unit.round || row.purpose !== unit.purpose || ![0, 1].includes(row.adopted) || (row.adopted === 1) !== (unit.adoptTaskId !== undefined) ||
|
|
604
|
+
unit.adoptTaskId !== undefined && unit.adoptTaskId !== row.task_id) throw researchFailure();
|
|
605
|
+
tasks.add(row.task_id);
|
|
606
|
+
return { campaignId: row.campaign_id, revision: row.revision, unitId: row.unit_id, taskId: row.task_id, round: row.round, purpose: unit.purpose, adopted: row.adopted === 1 };
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
researchCampaignBindings(id: string, revision: number): ResearchUnitBinding[] {
|
|
611
|
+
const campaign = this.researchCampaign(id, revision);
|
|
612
|
+
return campaign ? this.researchBindings(campaign) : [];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Most recent persisted association, including a unit omitted from intervening revisions. */
|
|
616
|
+
researchLatestUnitBinding(campaignId: string, unitId: string): ResearchUnitBinding | undefined {
|
|
617
|
+
if (!researchId(campaignId) || typeof unitId !== "string" || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(unitId)) throw researchFailure();
|
|
618
|
+
const row = this.db.query("SELECT revision FROM research_units WHERE campaign_id=? AND unit_id=? ORDER BY revision DESC LIMIT 1")
|
|
619
|
+
.get(campaignId, unitId) as { revision: number } | null;
|
|
620
|
+
if (!row) return undefined;
|
|
621
|
+
const campaign = this.researchCampaign(campaignId, row.revision);
|
|
622
|
+
if (!campaign) throw researchFailure();
|
|
623
|
+
const binding = this.researchBindings(campaign).find(unit => unit.unitId === unitId);
|
|
624
|
+
if (!binding) throw researchFailure();
|
|
625
|
+
return binding;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Lifetime unit association, including superseded/closed revisions; never reinterpret a task as another unit. */
|
|
629
|
+
researchTaskBinding(taskId: number): ResearchUnitBinding | undefined {
|
|
630
|
+
if (!researchInteger(taskId)) throw researchFailure();
|
|
631
|
+
const row = this.db.query("SELECT campaign_id,revision FROM research_units WHERE task_id=? ORDER BY campaign_id,revision,unit_id LIMIT 1").get(taskId) as { campaign_id: string; revision: number } | null;
|
|
632
|
+
if (!row) return undefined;
|
|
633
|
+
const campaign = this.researchCampaign(row.campaign_id, row.revision);
|
|
634
|
+
if (!campaign) throw researchFailure();
|
|
635
|
+
const binding = this.researchBindings(campaign).find(unit => unit.taskId === taskId);
|
|
636
|
+
if (!binding) throw researchFailure();
|
|
637
|
+
return binding;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Resolve lifetime membership against the latest revision; an omitted unit or a draft never inherits old dispatch approval. */
|
|
641
|
+
researchDispatchBinding(taskId: number): { campaign: ResearchCampaignRecord; binding?: ResearchUnitBinding } | undefined {
|
|
642
|
+
if (!researchInteger(taskId)) throw researchFailure();
|
|
643
|
+
const rows = this.db.query("SELECT DISTINCT campaign_id FROM research_units WHERE task_id=? ORDER BY campaign_id LIMIT 2").all(taskId) as { campaign_id: string }[];
|
|
644
|
+
if (!rows.length) return undefined;
|
|
645
|
+
if (rows.length !== 1) throw researchFailure();
|
|
646
|
+
const campaign = this.researchCampaign(rows[0]!.campaign_id);
|
|
647
|
+
if (!campaign) throw researchFailure();
|
|
648
|
+
const binding = this.researchBindings(campaign).find(unit => unit.taskId === taskId);
|
|
649
|
+
return { campaign, ...(binding ? { binding } : {}) };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private researchHandoffFromRow(row: ResearchHandoffRow): ResearchHandoffRecord {
|
|
653
|
+
try {
|
|
654
|
+
if (typeof row.body !== "string" || Buffer.byteLength(row.body, "utf8") > 65536 || !researchInteger(row.seq) || !researchInteger(row.at, 0) || !researchText(row.by_peer) || ![0, 1].includes(row.private)) throw researchFailure();
|
|
655
|
+
const handoff = parseResearchHandoff(JSON.parse(row.body)), campaign = this.researchCampaign(row.campaign_id, row.revision);
|
|
656
|
+
const unit = campaign?.contract.units.find(value => value.id === row.unit_id);
|
|
657
|
+
const binding = campaign && this.researchBindings(campaign).find(value => value.unitId === row.unit_id);
|
|
658
|
+
if (!campaign || !unit || !binding || handoff.campaignId !== row.campaign_id || handoff.revision !== row.revision || handoff.unitId !== row.unit_id || handoff.taskId !== row.task_id || binding.taskId !== row.task_id ||
|
|
659
|
+
handoff.evidence.some(entry => !unit.sourceIds.includes(entry.sourceId)) || handoff.contradictions.some(entry => entry.sourceIds.some(source => !unit.sourceIds.includes(source)))) throw researchFailure();
|
|
660
|
+
return { handoff, seq: row.seq, by: row.by_peer, at: row.at, private: row.private === 1 || campaign.private };
|
|
661
|
+
} catch { throw researchFailure(); }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** At most one latest handoff per unit; history remains append-only and older stable-unit evidence survives a revision. */
|
|
665
|
+
researchHandoffs(campaignId: string, revision?: number): ResearchHandoffRecord[] {
|
|
666
|
+
const campaign = this.researchCampaign(campaignId, revision);
|
|
667
|
+
if (!campaign) return [];
|
|
668
|
+
const rows = this.db.query(`SELECT campaign_id,unit_id,seq,revision,task_id,
|
|
669
|
+
CASE WHEN length(CAST(body AS BLOB))<=65536 THEN body END AS body,by_peer,at,private
|
|
670
|
+
FROM research_handoffs AS h WHERE campaign_id=? AND revision<=? AND unit_id IN (${campaign.contract.units.map(() => "?").join(",")}) AND seq=(SELECT MAX(seq) FROM research_handoffs AS old WHERE old.campaign_id=h.campaign_id AND old.unit_id=h.unit_id AND old.revision<=?) ORDER BY unit_id LIMIT 33`)
|
|
671
|
+
.all(campaignId, campaign.revision, ...campaign.contract.units.map(unit => unit.id), campaign.revision) as ResearchHandoffRow[];
|
|
672
|
+
if (rows.length > 32) throw researchFailure();
|
|
673
|
+
return rows.map(row => this.researchHandoffFromRow(row)).filter(row => campaign.contract.units.some(unit => unit.id === row.handoff.unitId));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Caller authority/privacy screening belongs to Tasks; the transaction re-checks every persisted binding and ownership fact. */
|
|
677
|
+
recordResearchHandoff(input: ResearchHandoffV1, project: string, by: PeerId, at: number, expectedTaskFlow: number): ResearchHandoffRecord {
|
|
678
|
+
const handoff = parseResearchHandoff(input);
|
|
679
|
+
if (!researchText(project) || !researchText(by) || !researchInteger(at, 0) || !researchInteger(expectedTaskFlow, 0)) throw researchFailure();
|
|
680
|
+
return this.atomic(() => {
|
|
681
|
+
const current = this.researchDispatchBinding(handoff.taskId), campaign = current?.campaign, binding = current?.binding;
|
|
682
|
+
const task = this.get(handoff.taskId), unit = campaign?.contract.units.find(value => value.id === handoff.unitId);
|
|
683
|
+
if (!campaign || campaign.id !== handoff.campaignId || campaign.revision !== handoff.revision || campaign.status !== "approved" || campaign.project !== project || !binding || binding.unitId !== handoff.unitId || !unit ||
|
|
684
|
+
!task || task.owner !== by || task.state !== "in_progress" || task.history.length !== expectedTaskFlow ||
|
|
685
|
+
handoff.evidence.some(entry => !unit.sourceIds.includes(entry.sourceId)) || handoff.contradictions.some(entry => entry.sourceIds.some(source => !unit.sourceIds.includes(source)))) throw researchFailure();
|
|
686
|
+
const last = this.db.query("SELECT MAX(seq) AS seq FROM research_handoffs WHERE campaign_id=? AND unit_id=?").get(campaign.id, unit.id) as { seq: number | null };
|
|
687
|
+
const seq = (last.seq ?? 0) + 1;
|
|
688
|
+
if (!researchInteger(seq)) throw researchFailure();
|
|
689
|
+
const privateRecord = campaign.private || task.signals.some(signal => signal === "pii" || signal === "pii_path");
|
|
690
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_handoffs(campaign_id,unit_id,seq,revision,task_id,body,by_peer,at,private) VALUES(?,?,?,?,?,?,?,?,?)")
|
|
691
|
+
.run(campaign.id, unit.id, seq, campaign.revision, task.id, JSON.stringify(handoff), by, at, privateRecord ? 1 : 0));
|
|
692
|
+
this.update(task.id, by, "study handoff", {}, `${campaign.id} revision ${campaign.revision}; evidence ${handoff.evidence.length}`, {}, expectedTaskFlow);
|
|
693
|
+
return { handoff, seq, by, at, private: privateRecord };
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
hasResearchDispatchState(): boolean {
|
|
698
|
+
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_campaigns) OR EXISTS(SELECT 1 FROM research_units) OR EXISTS(SELECT 1 FROM research_dispatch) OR EXISTS(SELECT 1 FROM research_handoffs) AS present").get() as { present: number }).present === 1;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** #603: a persisted study re-offer needs a target that knows `research_dropped_offers`; `research_dispatch` alone does not (the additive floor rule). */
|
|
702
|
+
hasResearchDroppedOffers(): boolean {
|
|
703
|
+
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_dropped_offers) AS present").get() as { present: number }).present === 1;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
researchDispatchIntegrity(): { activations: number; handoffs: number; digest: string } {
|
|
707
|
+
return this.db.transaction(() => {
|
|
708
|
+
const digest = createHash("sha256").update("agent-hub.research-dispatch-integrity/v1\n");
|
|
709
|
+
let activations = 0, handoffs = 0;
|
|
710
|
+
for (const row of this.db.query("SELECT campaign_id,revision,version,eligible_owners FROM research_dispatch ORDER BY campaign_id,revision").iterate() as Iterable<{ campaign_id: string; revision: number; version: number; eligible_owners: string | null }>) {
|
|
711
|
+
const campaign = this.researchCampaign(row.campaign_id, row.revision);
|
|
712
|
+
if (!campaign || campaign.dispatchVersion !== 1) throw researchFailure();
|
|
713
|
+
digest.update(JSON.stringify(row)).update("\n"); activations++;
|
|
714
|
+
}
|
|
715
|
+
for (const row of this.db.query("SELECT campaign_id,unit_id,seq,revision,task_id,CASE WHEN length(CAST(body AS BLOB))<=65536 THEN body END AS body,by_peer,at,private FROM research_handoffs ORDER BY campaign_id,unit_id,seq").iterate() as Iterable<ResearchHandoffRow>) {
|
|
716
|
+
const record = this.researchHandoffFromRow(row);
|
|
717
|
+
digest.update(JSON.stringify(record)).update("\n"); handoffs++;
|
|
718
|
+
}
|
|
719
|
+
// Dropped-offer rows join the digest only when there are any, so a project without one keeps the digest it always had.
|
|
720
|
+
for (const row of this.db.query("SELECT task_id,owner FROM research_dropped_offers ORDER BY task_id").iterate() as Iterable<{ task_id: number; owner: string }>) {
|
|
721
|
+
this.droppedOfferRow(row);
|
|
722
|
+
digest.update("dropped-offer\n").update(JSON.stringify(row)).update("\n");
|
|
723
|
+
}
|
|
724
|
+
return { activations, handoffs, digest: digest.digest("hex") };
|
|
725
|
+
})();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** A row names a task bound to a research unit in some revision (the drop happens while a newer draft has no binding yet) and a peer id; anything else is corrupt state. */
|
|
729
|
+
private droppedOfferRow(row: { task_id: number; owner: string }): { taskId: number; owner: string } {
|
|
730
|
+
if (!researchInteger(row.task_id) || !researchText(row.owner, 64) || !/^[a-z][a-z0-9_-]{0,63}$/.test(row.owner) || !this.db.query("SELECT 1 FROM research_units WHERE task_id=? LIMIT 1").get(row.task_id)) throw researchFailure();
|
|
731
|
+
return { taskId: row.task_id, owner: row.owner };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** #603: remember that a study hold discarded this owner's queued offer for the task. One row per task; a later drop replaces it. */
|
|
735
|
+
recordResearchDroppedOffer(taskId: number, owner: string): void {
|
|
736
|
+
this.droppedOfferRow({ task_id: taskId, owner });
|
|
737
|
+
this.db.query("INSERT INTO research_dropped_offers(task_id,owner) VALUES(?,?) ON CONFLICT(task_id) DO UPDATE SET owner=excluded.owner").run(taskId, owner);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
researchDroppedOffer(taskId: number): string | undefined {
|
|
741
|
+
if (!researchInteger(taskId)) throw researchFailure();
|
|
742
|
+
const row = this.db.query("SELECT task_id,owner FROM research_dropped_offers WHERE task_id=?").get(taskId) as { task_id: number; owner: string } | null;
|
|
743
|
+
return row ? this.droppedOfferRow(row).owner : undefined;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
clearResearchDroppedOffer(taskId: number): void {
|
|
747
|
+
if (!researchInteger(taskId)) throw researchFailure();
|
|
748
|
+
this.db.query("DELETE FROM research_dropped_offers WHERE task_id=?").run(taskId);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Bodies are append-only; a new revision cannot change its project's or parent's identity or lower privacy. */
|
|
752
|
+
saveResearchCampaignDraft(record: ResearchCampaignRecord, expectedLatest: number | undefined, expectedParentFlow: number): ResearchCampaignRecord {
|
|
753
|
+
const checked = checkedResearchCampaign(record);
|
|
754
|
+
if (checked.status !== "draft" || checked.approvedBy !== undefined || checked.closedBy !== undefined ||
|
|
755
|
+
expectedLatest !== undefined && !researchInteger(expectedLatest) || checked.revision !== (expectedLatest ?? 0) + 1 || !researchInteger(expectedParentFlow, 0)) throw researchFailure();
|
|
756
|
+
return this.atomic(() => {
|
|
757
|
+
const parent = this.get(checked.parentTaskId), latest = this.researchCampaign(checked.id);
|
|
758
|
+
if (!parent || parent.history.length !== expectedParentFlow || latest?.revision !== expectedLatest || latest &&
|
|
759
|
+
(latest.project !== checked.project || latest.parentTaskId !== checked.parentTaskId || latest.status === "closed")) throw researchFailure();
|
|
760
|
+
const stored = { ...checked, private: checked.private || latest?.private === true };
|
|
761
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_campaigns(id,project,parent_task_id,revision,status,body,body_sha256,private,created_by,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)")
|
|
762
|
+
.run(stored.id, stored.project, stored.parentTaskId, stored.revision, "draft", JSON.stringify(stored.contract), stored.hash, stored.private ? 1 : 0, stored.createdBy, stored.createdAt));
|
|
763
|
+
this.update(parent.id, stored.createdBy, latest ? "study revised" : "study drafted", {}, `${stored.id} revision ${stored.revision}`, {}, expectedParentFlow);
|
|
764
|
+
return this.researchCampaign(stored.id, stored.revision)!;
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** An approved replay recreates nothing; only monotone privacy tightening may write, without task history or approval changes. */
|
|
769
|
+
activateCampaign(id: string, revision: number, project: string, by: PeerId, at: number, prepared: readonly PreparedResearchUnit[], expectedParentFlow: number, privateRecord = false, dispatch?: { eligibleOwners?: readonly string[] }): { campaign: ResearchCampaignRecord; bindings: ResearchUnitBinding[]; children: Task[] } {
|
|
770
|
+
if (!researchText(project) || !researchText(by) || !researchInteger(at, 0) || typeof privateRecord !== "boolean") throw researchFailure();
|
|
771
|
+
return this.atomic(() => {
|
|
772
|
+
const campaign = this.researchCampaign(id, revision);
|
|
773
|
+
if (!campaign || campaign.project !== project) throw researchFailure();
|
|
774
|
+
if (campaign.status === "approved") {
|
|
775
|
+
const bindings = this.researchBindings(campaign);
|
|
776
|
+
if (privateRecord && !campaign.private) this.taskWrite(() => this.db.query("UPDATE research_campaigns SET private=1 WHERE id=? AND revision=? AND private=0").run(id, revision));
|
|
777
|
+
return { campaign: this.researchCampaign(id, revision)!, bindings, children: [] };
|
|
778
|
+
}
|
|
779
|
+
const owners = dispatch?.eligibleOwners === undefined ? undefined : [...dispatch.eligibleOwners];
|
|
780
|
+
if (owners && (owners.length < 1 || owners.length > 256 || owners.some(owner => !researchText(owner, 64) || !/^[a-z][a-z0-9_-]{0,63}$/.test(owner)) || new Set(owners).size !== owners.length) ||
|
|
781
|
+
dispatch !== undefined && !!campaign.contract.budget?.hardCap.length !== !!owners?.length) throw researchFailure();
|
|
782
|
+
const parent = this.get(campaign.parentTaskId), latest = this.researchCampaign(id);
|
|
783
|
+
const activeRow = this.db.query("SELECT revision FROM research_campaigns WHERE id=? AND status='approved' LIMIT 1").get(id) as { revision: number } | null;
|
|
784
|
+
const active = activeRow ? this.researchCampaign(id, activeRow.revision) : undefined;
|
|
785
|
+
if (campaign.status !== "draft" || latest?.revision !== revision || !researchInteger(expectedParentFlow, 0) || !parent || parent.history.length !== expectedParentFlow) throw researchFailure();
|
|
786
|
+
if (this.db.query("SELECT 1 FROM research_campaigns WHERE project=? AND parent_task_id=? AND status='approved' AND id<>? LIMIT 1").get(project, campaign.parentTaskId, id)) throw new Error("another research campaign is active for this parent");
|
|
787
|
+
if (this.researchBindings(campaign).length || prepared.length !== campaign.contract.units.length) throw researchFailure();
|
|
788
|
+
const inputs = new Map<string, PreparedResearchUnit>(), units = new Map(campaign.contract.units.map(unit => [unit.id, unit]));
|
|
789
|
+
const mapped = new Map<string, number>(), retained = new Map<string, Task>(), children: Task[] = [];
|
|
790
|
+
const isPrivate = campaign.private || privateRecord;
|
|
791
|
+
for (const entry of prepared) {
|
|
792
|
+
const unit = units.get(entry.unitId);
|
|
793
|
+
if (!unit || inputs.has(entry.unitId) || [entry.create, entry.adopt, entry.reuse].filter(value => value !== undefined).length !== 1) throw researchFailure();
|
|
794
|
+
inputs.set(entry.unitId, entry);
|
|
795
|
+
const prior = this.researchLatestUnitBinding(id, unit.id);
|
|
796
|
+
if (prior) {
|
|
797
|
+
const previous = this.researchCampaign(id, prior.revision), previousUnit = previous?.contract.units.find(value => value.id === unit.id);
|
|
798
|
+
if (!entry.reuse || entry.reuse.taskId !== prior.taskId || !researchInteger(entry.reuse.flowRevision, 0) || prior.revision >= revision ||
|
|
799
|
+
!previous || previous.project !== project || previous.parentTaskId !== campaign.parentTaskId || JSON.stringify(previousUnit) !== JSON.stringify(unit))
|
|
800
|
+
throw new Error("a bound research unit must retain its task and definition; changed work requires a fresh unit id");
|
|
801
|
+
const task = this.get(prior.taskId);
|
|
802
|
+
if (!task || task.history.length !== entry.reuse.flowRevision || isPrivate && !task.signals.some(signal => signal === "pii" || signal === "pii_path")) throw researchFailure();
|
|
803
|
+
mapped.set(unit.id, task.id); retained.set(unit.id, task);
|
|
804
|
+
} else if (entry.reuse) {
|
|
805
|
+
throw researchFailure();
|
|
806
|
+
} else if (entry.adopt) {
|
|
807
|
+
if (unit.adoptTaskId !== entry.adopt.taskId || !researchInteger(entry.adopt.flowRevision, 0)) throw researchFailure();
|
|
808
|
+
const task = this.get(entry.adopt.taskId);
|
|
809
|
+
if (!task || task.history.length !== entry.adopt.flowRevision || task.draft || !task.history.some(history => history.event === "draft approved") ||
|
|
810
|
+
isPrivate && !task.signals.some(signal => signal === "pii" || signal === "pii_path")) throw researchFailure();
|
|
811
|
+
if (this.db.query("SELECT 1 FROM research_units WHERE task_id=? AND (campaign_id<>? OR unit_id<>?) LIMIT 1").get(task.id, id, unit.id)) throw new Error("intake task is already bound to another research unit");
|
|
812
|
+
mapped.set(unit.id, task.id); retained.set(unit.id, task);
|
|
813
|
+
} else {
|
|
814
|
+
const create = entry.create!;
|
|
815
|
+
if (unit.adoptTaskId !== undefined || typeof create.title !== "string" || !create.title.trim() || create.title.length > 300 || !CLASSES.includes(create.class) || create.draft ||
|
|
816
|
+
isPrivate && !create.signals?.includes("pii")) throw researchFailure();
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const pending = campaign.contract.units.filter(unit => !mapped.has(unit.id));
|
|
820
|
+
while (pending.length) {
|
|
821
|
+
const next = pending.findIndex(unit => unit.dependsOn.every(dependency => mapped.has(dependency)));
|
|
822
|
+
if (next < 0) throw researchFailure();
|
|
823
|
+
const unit = pending.splice(next, 1)[0]!, create = inputs.get(unit.id)!.create!;
|
|
824
|
+
const deps = [...new Set([...(create.deps ?? []), ...unit.dependsOn.map(dependency => mapped.get(dependency)!)])];
|
|
825
|
+
if (deps.some(dependency => !researchInteger(dependency) || !this.get(dependency))) throw researchFailure();
|
|
826
|
+
const child = this.propose(by, { ...create, deps, backlog: true, draft: false });
|
|
827
|
+
mapped.set(unit.id, child.id); children.push(child);
|
|
828
|
+
}
|
|
829
|
+
for (const [unitId, task] of retained) if (units.get(unitId)!.dependsOn.some(dependency => !task.deps?.includes(mapped.get(dependency)!))) throw new Error("retained research task dependencies differ from the campaign");
|
|
830
|
+
for (const unit of campaign.contract.units) {
|
|
831
|
+
const taskId = mapped.get(unit.id)!;
|
|
832
|
+
if (this.db.query("SELECT 1 FROM research_units WHERE task_id=? AND (campaign_id<>? OR unit_id<>?) LIMIT 1").get(taskId, id, unit.id)) throw researchFailure();
|
|
833
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_units(campaign_id,revision,unit_id,task_id,round,purpose,adopted) VALUES(?,?,?,?,?,?,?)")
|
|
834
|
+
.run(id, revision, unit.id, taskId, unit.round, unit.purpose, unit.adoptTaskId !== undefined ? 1 : 0));
|
|
835
|
+
}
|
|
836
|
+
this.taskWrite(() => this.db.query("UPDATE research_campaigns SET status='superseded',private=MAX(private,?) WHERE id=? AND project=? AND status='approved'").run(isPrivate ? 1 : 0, id, project));
|
|
837
|
+
this.taskWrite(() => this.db.query("UPDATE research_campaigns SET status='approved',approved_by=?,approved_at=?,private=? WHERE id=? AND revision=? AND status='draft'")
|
|
838
|
+
.run(by, at, isPrivate ? 1 : 0, id, revision));
|
|
839
|
+
if (dispatch !== undefined) this.taskWrite(() => this.db.query("INSERT INTO research_dispatch(campaign_id,revision,version,eligible_owners) VALUES(?,?,1,?)")
|
|
840
|
+
.run(id, revision, owners === undefined ? null : JSON.stringify(owners)));
|
|
841
|
+
const nextUnits = new Set(campaign.contract.units.map(unit => unit.id));
|
|
842
|
+
const nextEdges = new Set(campaign.contract.units.flatMap(unit => unit.dependsOn.map(dependency => `${unit.id}:${dependency}`)));
|
|
843
|
+
const omittedUnits = active?.contract.units.filter(unit => !nextUnits.has(unit.id)).length ?? 0;
|
|
844
|
+
const omittedEdges = active?.contract.units.flatMap(unit => unit.dependsOn.map(dependency => `${unit.id}:${dependency}`)).filter(edge => !nextEdges.has(edge)).length ?? 0;
|
|
845
|
+
this.update(parent.id, by, "study approved", {}, `${id} revision ${revision}; omitted units ${omittedUnits}, dependencies ${omittedEdges}`, {}, expectedParentFlow);
|
|
846
|
+
const approved = this.researchCampaign(id, revision)!;
|
|
847
|
+
return { campaign: approved, bindings: this.researchBindings(approved), children };
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
closeCampaign(id: string, revision: number, project: string, by: PeerId, at: number, reason: string, expectedParentFlow: number, privateRecord = false): ResearchCampaignRecord {
|
|
852
|
+
if (!researchText(project) || !researchText(by) || !researchInteger(at, 0) || !researchText(reason, 4096) || typeof privateRecord !== "boolean") throw researchFailure();
|
|
853
|
+
return this.atomic(() => {
|
|
854
|
+
const campaign = this.researchCampaign(id, revision), latest = this.researchCampaign(id);
|
|
855
|
+
if (!campaign || campaign.project !== project || latest?.revision !== revision) throw researchFailure();
|
|
856
|
+
if (campaign.status === "closed") {
|
|
857
|
+
if (privateRecord && !campaign.private) this.taskWrite(() => this.db.query("UPDATE research_campaigns SET private=1 WHERE id=? AND revision=? AND private=0").run(id, revision));
|
|
858
|
+
return this.researchCampaign(id, revision)!;
|
|
859
|
+
}
|
|
860
|
+
const parent = this.get(campaign.parentTaskId);
|
|
861
|
+
if (!researchInteger(expectedParentFlow, 0) || !parent || parent.history.length !== expectedParentFlow) throw researchFailure();
|
|
862
|
+
this.taskWrite(() => this.db.query("UPDATE research_campaigns SET status='closed',closed_by=?,closed_at=?,closed_reason=?,private=MAX(private,?) WHERE id=? AND project=? AND (revision=? OR status='approved')")
|
|
863
|
+
.run(by, at, reason, campaign.private || privateRecord ? 1 : 0, id, project, revision));
|
|
864
|
+
this.update(parent.id, by, "study closed", {}, `${id} revision ${revision}`, {}, expectedParentFlow);
|
|
865
|
+
return this.researchCampaign(id, revision)!;
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
hasResearchState(): boolean {
|
|
870
|
+
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) AS present").get() as { present: number }).present === 1;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** A consistent, streaming digest includes metadata and body hashes, not merely row counts. */
|
|
874
|
+
researchIntegrity(): { campaigns: number; units: number; revision: number; digest: string } {
|
|
875
|
+
return this.db.transaction(() => {
|
|
876
|
+
const digest = createHash("sha256").update("agent-hub.research-integrity/v1\n");
|
|
877
|
+
let campaigns = 0, units = 0, revision = 0;
|
|
878
|
+
for (const row of this.db.query(`${RESEARCH_CAMPAIGN_SELECT} ORDER BY id,revision`).iterate() as Iterable<ResearchCampaignRow>) {
|
|
879
|
+
const campaign = researchCampaignFromRow(row), bindings = this.researchBindings(campaign);
|
|
880
|
+
const { contract: _body, ...metadata } = campaign;
|
|
881
|
+
digest.update(JSON.stringify(metadata)).update("\n");
|
|
882
|
+
for (const binding of bindings) digest.update(JSON.stringify(binding)).update("\n");
|
|
883
|
+
campaigns++; units += bindings.length; revision = Math.max(revision, campaign.revision);
|
|
884
|
+
}
|
|
885
|
+
if ((this.db.query("SELECT COUNT(*) AS count FROM research_units").get() as { count: number }).count !== units) throw researchFailure();
|
|
886
|
+
return { campaigns, units, revision, digest: digest.digest("hex") };
|
|
887
|
+
})();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private experimentFence(fence: ExperimentWriteFence): { task: Task; campaign: ResearchCampaignRecord } {
|
|
891
|
+
if (!researchText(fence.project) || !researchInteger(fence.taskId) || !researchInteger(fence.taskFlowRevision, 0) || !researchText(fence.owner) ||
|
|
892
|
+
!researchId(fence.campaignId) || !researchInteger(fence.campaignRevision) || !experimentHashValid(fence.campaignHash)) throw experimentFailure();
|
|
893
|
+
const task = this.get(fence.taskId), current = this.researchDispatchBinding(fence.taskId), campaign = current?.campaign;
|
|
894
|
+
if (!task || task.owner !== fence.owner || task.state !== "in_progress" || task.history.length !== fence.taskFlowRevision || !campaign || !current?.binding ||
|
|
895
|
+
campaign.project !== fence.project || campaign.id !== fence.campaignId || campaign.revision !== fence.campaignRevision || campaign.hash !== fence.campaignHash || campaign.status !== "approved") throw experimentFailure();
|
|
896
|
+
return { task, campaign };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
experimentProtocol(hash: string): ExperimentProtocolRecord | undefined {
|
|
900
|
+
if (!experimentHashValid(hash)) throw experimentFailure();
|
|
901
|
+
const row = this.db.query(`${EXPERIMENT_PROTOCOL_SELECT} WHERE hash=?`).get(hash) as ExperimentProtocolRow | null;
|
|
902
|
+
if (!row) return undefined;
|
|
903
|
+
try {
|
|
904
|
+
if (typeof row.body !== "string" || !researchText(row.project) || !researchInteger(row.task_id) || !researchText(row.by_peer) || !researchInteger(row.at, 0) || ![0, 1].includes(row.private)) throw experimentFailure();
|
|
905
|
+
const protocol = parseExperimentProtocol(JSON.parse(row.body));
|
|
906
|
+
if (protocolHash(protocol) !== row.hash || protocol.campaignId !== row.campaign_id || protocol.campaignRevision !== row.campaign_revision) throw experimentFailure();
|
|
907
|
+
const campaign = this.researchCampaign(protocol.campaignId, protocol.campaignRevision), task = this.get(row.task_id);
|
|
908
|
+
if (!campaign || campaign.project !== row.project || !task || !this.researchCampaignBindings(campaign.id, campaign.revision).some(binding => binding.taskId === task.id) ||
|
|
909
|
+
protocol.inputs.some(input => !campaign.contract.sourceIds.includes(input.sourceId))) throw experimentFailure();
|
|
910
|
+
return { hash: row.hash, project: row.project, recordedForTaskId: row.task_id, protocol, by: row.by_peer, at: row.at, private: row.private === 1 || campaign.private || task.signals.some(signal => signal === "pii" || signal === "pii_path") };
|
|
911
|
+
} catch { throw experimentFailure(); }
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
saveExperimentProtocol(record: ExperimentProtocolRecord, fence: ExperimentWriteFence): ExperimentProtocolRecord {
|
|
915
|
+
const protocol = parseExperimentProtocol(record.protocol), hash = protocolHash(protocol);
|
|
916
|
+
if (hash !== record.hash || record.project !== fence.project || record.recordedForTaskId !== fence.taskId || record.by !== fence.owner || !researchInteger(record.at, 0) || typeof record.private !== "boolean") throw experimentFailure();
|
|
917
|
+
return this.atomic(() => {
|
|
918
|
+
const { task, campaign } = this.experimentFence(fence);
|
|
919
|
+
if (protocol.campaignId !== campaign.id || protocol.campaignRevision !== campaign.revision || protocol.inputs.some(input => !campaign.contract.sourceIds.includes(input.sourceId))) throw experimentFailure();
|
|
920
|
+
if (protocol.variantOf !== undefined) {
|
|
921
|
+
const baseline = this.experimentProtocol(protocol.variantOf);
|
|
922
|
+
if (!baseline || baseline.project !== record.project || baseline.protocol.campaignId !== campaign.id || baseline.protocol.campaignRevision !== campaign.revision || baseline.protocol.role !== "baseline") throw experimentFailure();
|
|
923
|
+
}
|
|
924
|
+
if (protocol.supersedes !== undefined) {
|
|
925
|
+
const previous = this.experimentProtocol(protocol.supersedes);
|
|
926
|
+
if (!previous || previous.project !== record.project || previous.protocol.campaignId !== campaign.id || previous.protocol.lineage !== protocol.lineage) throw experimentFailure();
|
|
927
|
+
}
|
|
928
|
+
const priv = record.private || campaign.private || task.signals.some(signal => signal === "pii" || signal === "pii_path");
|
|
929
|
+
const previous = this.experimentProtocol(hash);
|
|
930
|
+
if (previous) {
|
|
931
|
+
if (previous.project !== record.project || previous.recordedForTaskId !== record.recordedForTaskId || canonicalJson(previous.protocol) !== canonicalJson(protocol)) throw experimentFailure();
|
|
932
|
+
if (priv) this.taskWrite(() => this.db.query("UPDATE research_protocols SET private=1 WHERE hash=? AND private=0").run(hash));
|
|
933
|
+
return this.experimentProtocol(hash)!;
|
|
934
|
+
}
|
|
935
|
+
// Capture recording order in this transaction, never from caller or wall-clock timestamps. Legacy rows are not backfilled.
|
|
936
|
+
const priorFinal = this.experimentPriorFinal(record.project, campaign.id, protocol.lineage);
|
|
937
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_protocols(hash,project,campaign_id,campaign_revision,task_id,body,by_peer,at,private) VALUES(?,?,?,?,?,?,?,?,?)")
|
|
938
|
+
.run(hash, record.project, campaign.id, campaign.revision, task.id, canonicalJson(protocol), record.by, record.at, priv ? 1 : 0));
|
|
939
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_protocol_context(protocol_hash,prior_attempt_id,prior_end_seq,context_sha256) VALUES(?,?,?,?)")
|
|
940
|
+
.run(hash, priorFinal?.attemptId ?? null, priorFinal?.endSeq ?? null, experimentHash({ protocolHash: hash, priorFinal: priorFinal ?? null })));
|
|
941
|
+
return this.experimentProtocol(hash)!;
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
experimentComparisonPlan(hash: string): ExperimentComparisonPlanRecord | undefined {
|
|
946
|
+
if (!experimentHashValid(hash)) throw experimentFailure();
|
|
947
|
+
const row = this.db.query(`${EXPERIMENT_PLAN_SELECT} WHERE hash=?`).get(hash) as ExperimentComparisonPlanRow | null;
|
|
948
|
+
return row ? this.experimentComparisonPlanFromRow(row) : undefined;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
private experimentComparisonPlanFromRow(row: ExperimentComparisonPlanRow): ExperimentComparisonPlanRecord {
|
|
952
|
+
try {
|
|
953
|
+
if (typeof row.body !== "string" || !researchText(row.project) || !researchInteger(row.task_id) || !researchText(row.by_peer) || !researchInteger(row.at, 0) || ![0, 1].includes(row.private)) throw experimentFailure();
|
|
954
|
+
const plan = parseComparisonPlan(JSON.parse(row.body)), campaign = this.researchCampaign(row.campaign_id, row.campaign_revision), task = this.get(row.task_id);
|
|
955
|
+
if (comparisonPlanHash(plan) !== row.hash || plan.campaignId !== row.campaign_id || !campaign || campaign.project !== row.project || !task ||
|
|
956
|
+
!this.researchCampaignBindings(campaign.id, campaign.revision).some(binding => binding.taskId === task.id) || plan.fixed.inputs.some(input => !campaign.contract.sourceIds.includes(input.sourceId))) throw experimentFailure();
|
|
957
|
+
return { hash: row.hash, project: row.project, recordedForTaskId: row.task_id, plan, by: row.by_peer, at: row.at,
|
|
958
|
+
private: row.private === 1 || campaign.private || task.signals.some(signal => signal === "pii" || signal === "pii_path") };
|
|
959
|
+
} catch { throw experimentFailure(); }
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
saveExperimentComparisonPlan(record: ExperimentComparisonPlanRecord, fence: ExperimentWriteFence): ExperimentComparisonPlanRecord {
|
|
963
|
+
const plan = parseComparisonPlan(record.plan), hash = comparisonPlanHash(plan);
|
|
964
|
+
if (hash !== record.hash || record.project !== fence.project || record.recordedForTaskId !== fence.taskId || record.by !== fence.owner || !researchInteger(record.at, 0) || typeof record.private !== "boolean") throw experimentFailure();
|
|
965
|
+
return this.atomic(() => {
|
|
966
|
+
const { task, campaign } = this.experimentFence(fence);
|
|
967
|
+
if (plan.campaignId !== campaign.id || plan.fixed.inputs.some(input => !campaign.contract.sourceIds.includes(input.sourceId))) throw experimentFailure();
|
|
968
|
+
const priv = record.private || campaign.private || task.signals.some(signal => signal === "pii" || signal === "pii_path"), previous = this.experimentComparisonPlan(hash);
|
|
969
|
+
if (previous) {
|
|
970
|
+
if (previous.project !== record.project || previous.recordedForTaskId !== record.recordedForTaskId || canonicalJson(previous.plan) !== canonicalJson(plan)) throw experimentFailure();
|
|
971
|
+
if (priv) this.taskWrite(() => this.db.query("UPDATE research_plans SET private=1 WHERE hash=? AND private=0").run(hash));
|
|
972
|
+
return this.experimentComparisonPlan(hash)!;
|
|
973
|
+
}
|
|
974
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_plans(hash,project,campaign_id,campaign_revision,task_id,body,by_peer,at,private) VALUES(?,?,?,?,?,?,?,?,?)")
|
|
975
|
+
.run(hash, record.project, campaign.id, campaign.revision, task.id, canonicalJson(plan), record.by, record.at, priv ? 1 : 0));
|
|
976
|
+
return this.experimentComparisonPlan(hash)!;
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
experimentComparisonPlans(project: string, campaignId: string, afterRow = 0, limit = 50): { rows: ExperimentComparisonPlanRecord[]; afterRow: number; done: boolean } {
|
|
981
|
+
if (!researchText(project) || !researchId(campaignId)) throw experimentFailure(); experimentPage(afterRow, limit);
|
|
982
|
+
const campaign = this.researchCampaign(campaignId); if (!campaign || campaign.project !== project) throw experimentFailure();
|
|
983
|
+
const page = this.db.query(`${EXPERIMENT_PLAN_SELECT} WHERE project=? AND campaign_id=? AND rowid>? ORDER BY rowid LIMIT ?`).all(project, campaignId, afterRow, limit + 1) as ExperimentComparisonPlanRow[];
|
|
984
|
+
return { rows: page.slice(0, limit).map(row => this.experimentComparisonPlanFromRow(row)), afterRow: page.slice(0, limit).at(-1)?.row_id ?? afterRow, done: page.length <= limit };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
experimentProtocolApproval(hash: string): ExperimentProtocolApproval | undefined {
|
|
988
|
+
const protocol = this.experimentProtocol(hash); if (!protocol) throw experimentFailure();
|
|
989
|
+
const row = this.db.query("SELECT protocol_hash,evaluator_hash,approved_by,at FROM research_protocol_approvals WHERE protocol_hash=?").get(hash) as ExperimentProtocolApprovalRow | null;
|
|
990
|
+
if (!row) return undefined;
|
|
991
|
+
if (row.protocol_hash !== hash || row.evaluator_hash !== evaluatorIdentityHash(protocol.protocol) || row.approved_by !== USER || !researchInteger(row.at, 0)) throw experimentFailure();
|
|
992
|
+
return { protocolHash: hash, evaluatorHash: row.evaluator_hash, approvedBy: row.approved_by, at: row.at };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** Explicit approval names one immutable historical or current protocol; nothing copies to a changed protocol. */
|
|
996
|
+
approveExperimentProtocol(project: string, hash: string, evaluatorHash: string, by: PeerId, at: number): ExperimentProtocolApproval {
|
|
997
|
+
if (by !== USER || !researchText(project) || !experimentHashValid(hash) || !experimentHashValid(evaluatorHash) || !researchInteger(at, 0)) throw experimentFailure();
|
|
998
|
+
return this.atomic(() => {
|
|
999
|
+
const protocol = this.experimentProtocol(hash);
|
|
1000
|
+
if (!protocol || protocol.project !== project || evaluatorIdentityHash(protocol.protocol) !== evaluatorHash) throw experimentFailure();
|
|
1001
|
+
const previous = this.experimentProtocolApproval(hash); if (previous) return previous;
|
|
1002
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_protocol_approvals(protocol_hash,evaluator_hash,approved_by,at) VALUES(?,?,?,?)").run(hash, evaluatorHash, by, at));
|
|
1003
|
+
return this.experimentProtocolApproval(hash)!;
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// ponytail: first insertion streams ended attempts until a matching lineage is found; index lineage/phase if campaign histories become large.
|
|
1008
|
+
private experimentPriorFinal(project: string, campaignId: string, lineage: string): { attemptId: string; endSeq: number } | undefined {
|
|
1009
|
+
const ends = this.db.query("SELECT e.attempt_id,e.seq FROM research_attempt_events AS e JOIN research_attempts AS a ON a.attempt_id=e.attempt_id WHERE a.project=? AND a.campaign_id=? AND e.kind='end' ORDER BY e.rowid DESC").iterate(project, campaignId) as Iterable<{ attempt_id: string; seq: number }>;
|
|
1010
|
+
for (const end of ends) {
|
|
1011
|
+
const attempt = this.experimentAttempt(end.attempt_id), protocol = attempt && this.experimentProtocol(attempt.attempt.protocolHash);
|
|
1012
|
+
if (!attempt || !protocol || attempt.project !== project || protocol.protocol.campaignId !== campaignId) throw experimentFailure();
|
|
1013
|
+
const event = this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE attempt_id=? AND seq=?`).get(end.attempt_id, end.seq) as ExperimentEventRow | null;
|
|
1014
|
+
if (!event || this.experimentEventFromRow(event).event.kind !== "end") throw experimentFailure();
|
|
1015
|
+
if (attempt.attempt.phase === "final" && protocol.protocol.lineage === lineage) return { attemptId: attempt.attemptId, endSeq: end.seq };
|
|
1016
|
+
}
|
|
1017
|
+
return undefined;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
experimentProtocolContext(hash: string): ExperimentProtocolContext {
|
|
1021
|
+
const protocol = this.experimentProtocol(hash); if (!protocol) throw experimentFailure();
|
|
1022
|
+
const row = this.db.query("SELECT protocol_hash,prior_attempt_id,prior_end_seq,context_sha256 FROM research_protocol_context WHERE protocol_hash=?").get(hash) as ExperimentProtocolContextRow | null;
|
|
1023
|
+
if (!row) return { state: "unknown" };
|
|
1024
|
+
if (row.protocol_hash !== hash || (row.prior_attempt_id === null) !== (row.prior_end_seq === null)) throw experimentFailure();
|
|
1025
|
+
if (row.prior_attempt_id === null) {
|
|
1026
|
+
if (row.context_sha256 !== experimentHash({ protocolHash: hash, priorFinal: null })) throw experimentFailure();
|
|
1027
|
+
return { state: "predeclared" };
|
|
1028
|
+
}
|
|
1029
|
+
if (!experimentIdValid(row.prior_attempt_id) || !researchInteger(row.prior_end_seq)) throw experimentFailure();
|
|
1030
|
+
const attempt = this.experimentAttempt(row.prior_attempt_id), previous = attempt && this.experimentProtocol(attempt.attempt.protocolHash);
|
|
1031
|
+
const event = this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE attempt_id=? AND seq=?`).get(row.prior_attempt_id, row.prior_end_seq) as ExperimentEventRow | null;
|
|
1032
|
+
const priorFinal = { attemptId: row.prior_attempt_id, endSeq: row.prior_end_seq };
|
|
1033
|
+
if (!attempt || !previous || !event || attempt.project !== protocol.project || attempt.attempt.phase !== "final" || previous.protocol.campaignId !== protocol.protocol.campaignId ||
|
|
1034
|
+
previous.protocol.lineage !== protocol.protocol.lineage || this.experimentEventFromRow(event).event.kind !== "end" || row.context_sha256 !== experimentHash({ protocolHash: hash, priorFinal })) throw experimentFailure();
|
|
1035
|
+
return { state: "post-hoc", priorFinal };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
hasResearchComparisonState(): boolean {
|
|
1039
|
+
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_plans) OR EXISTS(SELECT 1 FROM research_protocol_approvals) OR EXISTS(SELECT 1 FROM research_protocol_context) AS present").get() as { present: number }).present === 1;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
researchComparisonIntegrity(): { plans: number; approvals: number; contexts: number; digest: string } {
|
|
1043
|
+
return this.db.transaction(() => {
|
|
1044
|
+
const digest = createHash("sha256").update("agent-hub.research-comparison-integrity/v1\n");
|
|
1045
|
+
let plans = 0, approvals = 0, contexts = 0;
|
|
1046
|
+
for (const row of this.db.query(`${EXPERIMENT_PLAN_SELECT} ORDER BY hash`).iterate() as Iterable<ExperimentComparisonPlanRow>) {
|
|
1047
|
+
digest.update(canonicalJson({ record: this.experimentComparisonPlanFromRow(row), campaignRevision: row.campaign_revision })).update("\n"); plans++;
|
|
1048
|
+
}
|
|
1049
|
+
for (const row of this.db.query("SELECT protocol_hash,evaluator_hash,approved_by,at FROM research_protocol_approvals ORDER BY protocol_hash").iterate() as Iterable<ExperimentProtocolApprovalRow>) {
|
|
1050
|
+
digest.update(canonicalJson(this.experimentProtocolApproval(row.protocol_hash))).update("\n"); approvals++;
|
|
1051
|
+
}
|
|
1052
|
+
for (const row of this.db.query("SELECT protocol_hash FROM research_protocol_context ORDER BY protocol_hash").iterate() as Iterable<{ protocol_hash: string }>) {
|
|
1053
|
+
digest.update(canonicalJson({ protocolHash: row.protocol_hash, context: this.experimentProtocolContext(row.protocol_hash) })).update("\n"); contexts++;
|
|
1054
|
+
}
|
|
1055
|
+
return { plans, approvals, contexts, digest: digest.digest("hex") };
|
|
1056
|
+
})();
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
experimentAttempt(id: string): ExperimentAttemptRecord | undefined {
|
|
1060
|
+
if (!experimentIdValid(id)) throw experimentFailure();
|
|
1061
|
+
const row = this.db.query(`${EXPERIMENT_ATTEMPT_SELECT} WHERE attempt_id=?`).get(id) as ExperimentAttemptRow | null;
|
|
1062
|
+
return row ? this.experimentAttemptFromRow(row) : undefined;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
private experimentAttemptFromRow(row: ExperimentAttemptRow): ExperimentAttemptRecord {
|
|
1066
|
+
try {
|
|
1067
|
+
if (!experimentIdValid(row.attempt_id) || typeof row.body !== "string" || !researchText(row.project) || !researchText(row.by_peer) || !researchInteger(row.at, 0) || ![0, 1].includes(row.private)) throw experimentFailure();
|
|
1068
|
+
const attempt = parseExperimentAttempt(JSON.parse(row.body)), protocol = this.experimentProtocol(row.protocol_hash);
|
|
1069
|
+
if (experimentHash(attempt) !== row.body_sha256 || attempt.protocolHash !== row.protocol_hash || attempt.taskId !== row.task_id || attempt.idempotencyKey !== row.idempotency_key ||
|
|
1070
|
+
!protocol || protocol.project !== row.project || protocol.protocol.campaignId !== row.campaign_id || protocol.protocol.campaignRevision !== row.campaign_revision ||
|
|
1071
|
+
!this.researchCampaignBindings(row.campaign_id, row.campaign_revision).some(binding => binding.taskId === attempt.taskId)) throw experimentFailure();
|
|
1072
|
+
const privateEvent = !!this.db.query("SELECT 1 FROM research_attempt_events WHERE attempt_id=? AND private=1 LIMIT 1").get(row.attempt_id);
|
|
1073
|
+
return { attemptId: row.attempt_id, project: row.project, attempt, bodyHash: row.body_sha256, by: row.by_peer, at: row.at, private: row.private === 1 || protocol.private || privateEvent };
|
|
1074
|
+
} catch { throw experimentFailure(); }
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
createExperimentAttempt(record: ExperimentAttemptRecord, fence: ExperimentWriteFence): ExperimentAttemptRecord {
|
|
1078
|
+
const attempt = parseExperimentAttempt(record.attempt), bodyHash = experimentHash(attempt);
|
|
1079
|
+
if (!experimentIdValid(record.attemptId) || record.bodyHash !== bodyHash || record.project !== fence.project || attempt.taskId !== fence.taskId || record.by !== fence.owner || !researchInteger(record.at, 0) || typeof record.private !== "boolean") throw experimentFailure();
|
|
1080
|
+
return this.atomic(() => {
|
|
1081
|
+
const { campaign } = this.experimentFence(fence), protocol = this.experimentProtocol(attempt.protocolHash);
|
|
1082
|
+
const origin = protocol && this.researchDispatchBinding(protocol.recordedForTaskId);
|
|
1083
|
+
if (!protocol || protocol.project !== record.project || protocol.protocol.campaignId !== campaign.id || protocol.protocol.campaignRevision !== campaign.revision ||
|
|
1084
|
+
!origin?.binding || origin.campaign.id !== campaign.id || origin.campaign.revision !== campaign.revision || origin.campaign.status !== "approved") throw experimentFailure();
|
|
1085
|
+
const previous = this.db.query(`${EXPERIMENT_ATTEMPT_SELECT} WHERE project=? AND idempotency_key=?`).get(record.project, attempt.idempotencyKey) as ExperimentAttemptRow | null;
|
|
1086
|
+
if (previous) {
|
|
1087
|
+
const stored = this.experimentAttemptFromRow(previous);
|
|
1088
|
+
if (stored.bodyHash !== bodyHash || canonicalJson(stored.attempt) !== canonicalJson(attempt)) throw new Error("attempt idempotency key already records different data");
|
|
1089
|
+
if (record.private || protocol.private) this.taskWrite(() => this.db.query("UPDATE research_attempts SET private=1 WHERE attempt_id=? AND private=0").run(stored.attemptId));
|
|
1090
|
+
return this.experimentAttempt(stored.attemptId)!;
|
|
1091
|
+
}
|
|
1092
|
+
for (const linked of [attempt.retryOf, attempt.reproducesAttemptId]) if (linked !== undefined) {
|
|
1093
|
+
const prior = this.experimentAttempt(linked), priorProtocol = prior && this.experimentProtocol(prior.attempt.protocolHash);
|
|
1094
|
+
if (!prior || !priorProtocol || prior.project !== record.project || priorProtocol.protocol.campaignId !== campaign.id) throw experimentFailure();
|
|
1095
|
+
}
|
|
1096
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_attempts(attempt_id,project,campaign_id,campaign_revision,protocol_hash,task_id,idempotency_key,body,body_sha256,by_peer,at,private) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)")
|
|
1097
|
+
.run(record.attemptId, record.project, campaign.id, campaign.revision, attempt.protocolHash, attempt.taskId, attempt.idempotencyKey, canonicalJson(attempt), bodyHash, record.by, record.at, record.private || protocol.private ? 1 : 0));
|
|
1098
|
+
return this.experimentAttempt(record.attemptId)!;
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
private experimentEventFromRow(row: ExperimentEventRow): ExperimentEventRecord {
|
|
1103
|
+
try {
|
|
1104
|
+
if (typeof row.body !== "string" || !researchInteger(row.seq) || !researchInteger(row.at, 0) || !researchText(row.by_peer) || ![0, 1].includes(row.private) || !researchText(row.idempotency_key, 64)) throw experimentFailure();
|
|
1105
|
+
const attempt = this.experimentAttempt(row.attempt_id), protocol = attempt && this.experimentProtocol(attempt.attempt.protocolHash);
|
|
1106
|
+
if (!attempt || !protocol || row.project !== attempt.project) throw experimentFailure();
|
|
1107
|
+
const input = parseExperimentEvent(JSON.parse(row.body), protocol.protocol);
|
|
1108
|
+
if (input.kind !== row.kind || experimentHash(input) !== row.body_sha256) throw experimentFailure();
|
|
1109
|
+
const event = { ...input, seq: row.seq, at: row.at, provenance: "declared" as const };
|
|
1110
|
+
const observations = (this.db.query("SELECT attempt_id,event_seq,CASE WHEN length(CAST(body AS BLOB))<=65536 THEN body END AS body,body_sha256 FROM research_attempt_observations WHERE attempt_id=? AND event_seq=?").all(row.attempt_id, row.seq) as ExperimentObservationRow[]).map(observed => {
|
|
1111
|
+
if (typeof observed.body !== "string" || observed.attempt_id !== row.attempt_id || observed.event_seq !== row.seq) throw experimentFailure();
|
|
1112
|
+
const observation = parseExperimentHostEvidence(JSON.parse(observed.body));
|
|
1113
|
+
if (experimentHash(observation) !== observed.body_sha256) throw experimentFailure();
|
|
1114
|
+
return { eventSeq: row.seq, observation };
|
|
1115
|
+
});
|
|
1116
|
+
projectExperimentWithObservations(protocol.protocol, [event], observations);
|
|
1117
|
+
return { attemptId: row.attempt_id, idempotencyKey: row.idempotency_key, event, bodyHash: row.body_sha256, by: row.by_peer, private: row.private === 1 || attempt.private, observations };
|
|
1118
|
+
} catch { throw experimentFailure(); }
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
appendExperimentEvent(attemptId: string, key: string, input: DeclaredExperimentEventInput, hostEvidence: readonly ExperimentHostEvidence[], by: PeerId, at: number, priv: boolean, expectedLastSeq: number, fence: ExperimentWriteFence): ExperimentEventRecord {
|
|
1122
|
+
if (!experimentIdValid(attemptId) || !researchText(key, 64) || by !== fence.owner || !researchInteger(at, 0) || typeof priv !== "boolean" || !researchInteger(expectedLastSeq, 0) || !Array.isArray(hostEvidence) || hostEvidence.length > 1) throw experimentFailure();
|
|
1123
|
+
return this.atomic(() => {
|
|
1124
|
+
const { campaign } = this.experimentFence(fence), attempt = this.experimentAttempt(attemptId), protocol = attempt && this.experimentProtocol(attempt.attempt.protocolHash);
|
|
1125
|
+
if (!attempt || !protocol || attempt.project !== fence.project || attempt.attempt.taskId !== fence.taskId || protocol.protocol.campaignId !== campaign.id || protocol.protocol.campaignRevision !== campaign.revision) throw experimentFailure();
|
|
1126
|
+
const value = parseExperimentEvent(input, protocol.protocol), bodyHash = experimentHash(value);
|
|
1127
|
+
const previous = this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE project=? AND idempotency_key=?`).get(fence.project, key) as ExperimentEventRow | null;
|
|
1128
|
+
if (previous) {
|
|
1129
|
+
const stored = this.experimentEventFromRow(previous);
|
|
1130
|
+
if (stored.attemptId !== attemptId || stored.bodyHash !== bodyHash || canonicalJson({ kind: stored.event.kind, body: stored.event.body }) !== canonicalJson(value)) throw new Error("event idempotency key already records different data");
|
|
1131
|
+
if (priv || attempt.private) {
|
|
1132
|
+
this.taskWrite(() => this.db.query("UPDATE research_attempt_events SET private=1 WHERE attempt_id=? AND seq=? AND private=0").run(attemptId, stored.event.seq));
|
|
1133
|
+
this.taskWrite(() => this.db.query("UPDATE research_attempts SET private=1 WHERE attempt_id=? AND private=0").run(attemptId));
|
|
1134
|
+
}
|
|
1135
|
+
return this.experimentEventFromRow(this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE attempt_id=? AND seq=?`).get(attemptId, stored.event.seq) as ExperimentEventRow);
|
|
1136
|
+
}
|
|
1137
|
+
if (this.experimentLastSeq(attemptId) !== expectedLastSeq || !researchInteger(expectedLastSeq + 1)) throw new Error("experiment event sequence changed");
|
|
1138
|
+
if (value.kind === "end" && this.db.query("SELECT 1 FROM research_attempt_events WHERE attempt_id=? AND kind='end'").get(attemptId)) throw new Error("an experiment attempt has at most one end event");
|
|
1139
|
+
const seq = expectedLastSeq + 1, observations = hostEvidence.map(observation => ({ eventSeq: seq, observation: parseExperimentHostEvidence(observation) }));
|
|
1140
|
+
projectExperimentWithObservations(protocol.protocol, [{ ...value, seq, at, provenance: "declared" }], observations);
|
|
1141
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_attempt_events(attempt_id,project,seq,idempotency_key,kind,body,body_sha256,by_peer,at,private) VALUES(?,?,?,?,?,?,?,?,?,?)")
|
|
1142
|
+
.run(attemptId, fence.project, seq, key, value.kind, canonicalJson(value), bodyHash, by, at, priv || attempt.private ? 1 : 0));
|
|
1143
|
+
for (const observed of observations) this.taskWrite(() => this.db.query("INSERT INTO research_attempt_observations(attempt_id,event_seq,body,body_sha256) VALUES(?,?,?,?)")
|
|
1144
|
+
.run(attemptId, seq, canonicalJson(observed.observation), experimentHash(observed.observation)));
|
|
1145
|
+
if (priv || attempt.private) this.taskWrite(() => this.db.query("UPDATE research_attempts SET private=1 WHERE attempt_id=? AND private=0").run(attemptId));
|
|
1146
|
+
return this.experimentEventFromRow(this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE attempt_id=? AND seq=?`).get(attemptId, seq) as ExperimentEventRow);
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
experimentLastSeq(attemptId: string): number {
|
|
1151
|
+
if (!this.experimentAttempt(attemptId)) throw experimentFailure();
|
|
1152
|
+
const last = this.db.query("SELECT MAX(seq) AS seq,COUNT(*) AS count FROM research_attempt_events WHERE attempt_id=?").get(attemptId) as { seq: number | null; count: number };
|
|
1153
|
+
const seq = last.seq ?? 0;
|
|
1154
|
+
if (!researchInteger(seq, 0) || last.count !== seq) throw experimentFailure();
|
|
1155
|
+
return seq;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
experimentEvents(attemptId: string, afterSeq = 0, limit = 50): { rows: ExperimentEventRecord[]; afterSeq: number; done: boolean } {
|
|
1159
|
+
if (!this.experimentAttempt(attemptId)) throw experimentFailure(); experimentPage(afterSeq, limit);
|
|
1160
|
+
const page = this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE attempt_id=? AND seq>? ORDER BY seq LIMIT ?`).all(attemptId, afterSeq, limit + 1) as ExperimentEventRow[];
|
|
1161
|
+
const rows = page.slice(0, limit).map(row => this.experimentEventFromRow(row));
|
|
1162
|
+
return { rows, afterSeq: rows.at(-1)?.event.seq ?? afterSeq, done: page.length <= limit };
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
experimentAttempts(project: string, campaignId: string, afterRow = 0, limit = 50): { rows: ExperimentAttemptRecord[]; afterRow: number; done: boolean } {
|
|
1166
|
+
if (!researchText(project) || !researchId(campaignId)) throw experimentFailure(); experimentPage(afterRow, limit);
|
|
1167
|
+
const campaign = this.researchCampaign(campaignId); if (!campaign || campaign.project !== project) throw experimentFailure();
|
|
1168
|
+
const page = this.db.query(`${EXPERIMENT_ATTEMPT_SELECT} WHERE project=? AND rowid>? AND campaign_id=? ORDER BY rowid LIMIT ?`)
|
|
1169
|
+
.all(project, afterRow, campaignId, limit + 1) as ExperimentAttemptRow[];
|
|
1170
|
+
return { rows: page.slice(0, limit).map(row => this.experimentAttemptFromRow(row)), afterRow: page.slice(0, limit).at(-1)?.row_id ?? afterRow, done: page.length <= limit };
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
experimentAttemptStatus(attemptId: string): ExperimentStatus {
|
|
1174
|
+
const attempt = this.experimentAttempt(attemptId);
|
|
1175
|
+
if (!attempt) throw experimentFailure();
|
|
1176
|
+
const endRows = this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE attempt_id=? AND kind='end' ORDER BY seq LIMIT 2`).all(attemptId) as ExperimentEventRow[];
|
|
1177
|
+
if (endRows.length > 1) throw experimentFailure();
|
|
1178
|
+
const end = endRows[0], start = !end && this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE attempt_id=? AND kind='start' ORDER BY seq LIMIT 1`).get(attemptId) as ExperimentEventRow | null;
|
|
1179
|
+
const events = end ? [this.experimentEventFromRow(end).event] : start ? [this.experimentEventFromRow(start).event] : [];
|
|
1180
|
+
return experimentStatus(events, this.get(attempt.attempt.taskId)?.state);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// ponytail: exact counts validate all campaign attempts on each show (linear in attempt count). Upgrade to a transactional status projection maintained on event and task-state changes.
|
|
1184
|
+
experimentSummary(project: string, campaignId: string): { total: number; statuses: Record<ExperimentStatus, number> } {
|
|
1185
|
+
const campaign = this.researchCampaign(campaignId); if (!campaign || campaign.project !== project) throw experimentFailure();
|
|
1186
|
+
const statuses: Record<ExperimentStatus, number> = { declared: 0, running: 0, succeeded: 0, failed: 0, cancelled: 0, unknown: 0 };
|
|
1187
|
+
let total = 0;
|
|
1188
|
+
for (const row of this.db.query(`${EXPERIMENT_ATTEMPT_SELECT} WHERE project=? AND campaign_id=? ORDER BY rowid`).iterate(project, campaignId) as Iterable<ExperimentAttemptRow>) {
|
|
1189
|
+
const attempt = this.experimentAttemptFromRow(row);
|
|
1190
|
+
// Status needs only the unique terminal event or presence of a start, not an unbounded event read.
|
|
1191
|
+
statuses[this.experimentAttemptStatus(attempt.attemptId)]++; total++;
|
|
1192
|
+
}
|
|
1193
|
+
return { total, statuses };
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
hasResearchExperimentState(): boolean {
|
|
1197
|
+
return (this.db.query("SELECT 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) AS present").get() as { present: number }).present === 1;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
researchExperimentsIntegrity(): { protocols: number; attempts: number; events: number; observations: number; digest: string } {
|
|
1201
|
+
return this.db.transaction(() => {
|
|
1202
|
+
const digest = createHash("sha256").update("agent-hub.research-experiments-integrity/v1\n");
|
|
1203
|
+
let protocols = 0, attempts = 0, events = 0, observations = 0;
|
|
1204
|
+
for (const row of this.db.query(`${EXPERIMENT_PROTOCOL_SELECT} ORDER BY hash`).iterate() as Iterable<ExperimentProtocolRow>) {
|
|
1205
|
+
digest.update(canonicalJson(this.experimentProtocol(row.hash))).update("\n"); protocols++;
|
|
1206
|
+
}
|
|
1207
|
+
for (const row of this.db.query(`${EXPERIMENT_ATTEMPT_SELECT} ORDER BY attempt_id`).iterate() as Iterable<ExperimentAttemptRow>) {
|
|
1208
|
+
digest.update(canonicalJson(this.experimentAttemptFromRow(row))).update("\n"); attempts++;
|
|
1209
|
+
}
|
|
1210
|
+
let currentAttempt = "", nextSeq = 0, ended = false;
|
|
1211
|
+
for (const row of this.db.query(`${EXPERIMENT_EVENT_SELECT} ORDER BY attempt_id,seq`).iterate() as Iterable<ExperimentEventRow>) {
|
|
1212
|
+
if (row.attempt_id !== currentAttempt) { currentAttempt = row.attempt_id; nextSeq = 1; ended = false; }
|
|
1213
|
+
if (row.seq !== nextSeq++ || row.kind === "end" && ended) throw experimentFailure();
|
|
1214
|
+
if (row.kind === "end") ended = true;
|
|
1215
|
+
const record = this.experimentEventFromRow(row);
|
|
1216
|
+
digest.update(canonicalJson(record)).update("\n"); events++; observations += record.observations.length;
|
|
1217
|
+
}
|
|
1218
|
+
if ((this.db.query("SELECT COUNT(*) AS count FROM research_attempt_observations").get() as { count: number }).count !== observations) throw experimentFailure();
|
|
1219
|
+
return { protocols, attempts, events, observations, digest: digest.digest("hex") };
|
|
1220
|
+
})();
|
|
357
1221
|
}
|
|
358
1222
|
|
|
359
1223
|
propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; planner?: PeerId | null; plannerReason?: PlannerReason; profile?: import("./profiles.ts").ProfileName; profileClass?: string; customProfile?: true; role?: string; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; reserved?: PeerId; signals?: string[]; backlog?: boolean; draft?: boolean; external?: GitHubSource }): Task {
|
|
@@ -494,8 +1358,13 @@ export class Board {
|
|
|
494
1358
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
495
1359
|
const untouchedReturn = event === "backlog returned untouched" && patch.state === "backlog" && patch.owner === null && patch.reviewer === null
|
|
496
1360
|
&& ["proposed", "in_progress"].includes(task.state) && expectedFlowRevision !== undefined && !!note?.trim() && !taskHasWorkEvidence(task);
|
|
1361
|
+
// A new delivered head with a configured check is owner work again until that check finishes.
|
|
1362
|
+
const reviewCheck = event === "done (checking)" && task.state === "in_review" && patch.state === "in_progress"
|
|
1363
|
+
&& expectedFlowRevision !== undefined && (by === task.owner || by === "user") && patch.owner === undefined && patch.reviewer === undefined
|
|
1364
|
+
&& typeof patch.refs?.commit === "string" && /^[a-f0-9]{40}$/.test(patch.refs.commit)
|
|
1365
|
+
&& patch.refs.commit !== (task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead);
|
|
497
1366
|
if (task.draft && patch.state && patch.state !== "backlog" && patch.state !== "cancelled") throw new Error(`task #${id} is an intake draft; the person approves it first`);
|
|
498
|
-
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state) && !untouchedReturn) {
|
|
1367
|
+
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state) && !untouchedReturn && !reviewCheck) {
|
|
499
1368
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
500
1369
|
}
|
|
501
1370
|
const planner = Object.hasOwn(patch, "planner") ? plannerValue(patch.planner) : task.planner ?? null;
|