zigmini 1.0.0-beta.3 → 1.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +27 -2
- package/README.md +27 -2
- package/assets/agent/gateway-request-rules.md +1 -1
- package/assets/agent/gateway-rules.md +6 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/assets/document-reader/document-reader +0 -0
- package/assets/document-reader/host-selector +0 -0
- package/assets/document-reader/manifest.json +27 -0
- package/docs/manual.ko.md +114 -10
- package/docs/manual.md +117 -11
- package/host/document-reader/DocumentRead.swift +370 -0
- package/host/document-reader/HostSelector.swift +192 -0
- package/host/document-reader/Protocol.swift +647 -0
- package/host/document-reader/main.swift +22 -0
- package/host/omo-bridge/agent-aside-tool.ts +119 -0
- package/host/omo-bridge/agent-aside.ts +522 -0
- package/host/omo-bridge/agent-collaboration-files.ts +282 -0
- package/host/omo-bridge/agent-collaboration.ts +385 -0
- package/host/omo-bridge/agent-control.ts +400 -28
- package/host/omo-bridge/agent-execution.sb +42 -0
- package/host/omo-bridge/agent-execution.ts +158 -0
- package/host/omo-bridge/agent-tool-adapters.ts +388 -0
- package/host/omo-bridge/agent-tool-permissions.ts +96 -0
- package/host/omo-bridge/aside-tabs.ts +41 -0
- package/host/omo-bridge/attachment-read.ts +100 -0
- package/host/omo-bridge/bridge.ts +10 -0
- package/host/omo-bridge/managed-call.ts +46 -0
- package/host/omo-bridge/task-control.ts +39 -5
- package/host/omo-check/patches/attachments-293/README.md +138 -0
- package/host/omo-check/patches/attachments-293/document-content.js +234 -0
- package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
- package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
- package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
- package/package.json +5 -3
- package/src/agent/api.ts +345 -20
- package/src/agent/artifacts.ts +196 -0
- package/src/agent/aside-target-routes.ts +369 -0
- package/src/agent/aside-targets.ts +215 -0
- package/src/agent/collaboration-admission.ts +47 -0
- package/src/agent/collaboration-read.ts +117 -0
- package/src/agent/collaboration-session.ts +196 -0
- package/src/agent/collaboration-types.ts +258 -0
- package/src/agent/collaboration.ts +399 -0
- package/src/agent/definition.ts +65 -3
- package/src/agent/fields.ts +9 -3
- package/src/agent/file-core.ts +325 -0
- package/src/agent/files.ts +87 -8
- package/src/agent/list.ts +3 -3
- package/src/agent/managed-call.ts +144 -0
- package/src/agent/tool-catalog.ts +184 -0
- package/src/agent/tool-delivery.ts +359 -0
- package/src/agent/tool-permission-types.ts +81 -0
- package/src/agent/tool-permissions.ts +1366 -0
- package/src/attachments/api.ts +161 -0
- package/src/attachments/document-reader.ts +325 -0
- package/src/attachments/host-selector.ts +410 -0
- package/src/attachments/managed-binding.ts +83 -0
- package/src/attachments/prepare.ts +214 -0
- package/src/attachments/selector-port.ts +28 -0
- package/src/attachments/service.ts +729 -0
- package/src/attachments/store.ts +698 -0
- package/src/attachments/types.ts +123 -0
- package/src/cli/doctor.ts +30 -10
- package/src/decisions.ts +109 -10
- package/src/engine/apply-patch.ts +21 -0
- package/src/engine/compat.json +2 -1
- package/src/engine/install.ts +45 -4
- package/src/engine/sandbox-cell.ts +2 -0
- package/src/gc.ts +33 -225
- package/src/housekeeping/api.ts +163 -0
- package/src/housekeeping/audit.ts +61 -0
- package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
- package/src/housekeeping/collectors/agent-drafts.ts +43 -0
- package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
- package/src/housekeeping/collectors/agent-trash.ts +28 -0
- package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
- package/src/housekeeping/collectors/db-backup.ts +25 -0
- package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
- package/src/housekeeping/collectors/docker-image.ts +135 -0
- package/src/housekeeping/collectors/omo-version.ts +32 -0
- package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
- package/src/housekeeping/collectors/reconcile-work.ts +55 -0
- package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
- package/src/housekeeping/collectors/shared.ts +149 -0
- package/src/housekeeping/collectors/temp-dir.ts +28 -0
- package/src/housekeeping/collectors/update-file.ts +25 -0
- package/src/housekeeping/collectors/worktree.ts +193 -0
- package/src/housekeeping/engine.ts +125 -0
- package/src/housekeeping/reconcile/work.ts +130 -0
- package/src/housekeeping/registry.ts +63 -0
- package/src/housekeeping/trash.ts +59 -0
- package/src/housekeeping/types.ts +31 -0
- package/src/hub.ts +1095 -41
- package/src/main.ts +228 -30
- package/src/message-references.ts +14 -5
- package/src/profile-prompt.ts +13 -1
- package/src/protocol.ts +41 -6
- package/src/security-allow.ts +79 -2
- package/src/security-check.ts +1 -1
- package/src/store.ts +1946 -23
- package/src/test-crash.ts +20 -0
- package/src/work-watch.ts +55 -7
- package/src/work.ts +192 -3
- package/web/DESIGN.md +33 -0
- package/web/agents.d.ts +189 -12
- package/web/agents.js +898 -39
- package/web/app.js +2634 -210
- package/web/attachments.d.ts +91 -0
- package/web/attachments.js +422 -0
- package/web/collaboration.d.ts +87 -0
- package/web/collaboration.js +290 -0
- package/web/decisions.d.ts +39 -2
- package/web/decisions.js +354 -2
- package/web/gc.d.ts +26 -5
- package/web/gc.js +113 -13
- package/web/i18n/agents.d.ts +4 -0
- package/web/i18n/agents.js +200 -8
- package/web/i18n/attachments.js +78 -0
- package/web/i18n/decisions.d.ts +4 -0
- package/web/i18n/decisions.js +68 -0
- package/web/i18n/gc.d.ts +4 -0
- package/web/i18n/gc.js +184 -2
- package/web/i18n/main.js +80 -8
- package/web/i18n/tour.d.ts +4 -0
- package/web/i18n/tour.js +37 -0
- package/web/index.html +20 -3
- package/web/profile.d.ts +2 -2
- package/web/profile.js +11 -6
- package/web/style.css +342 -21
- package/web/tooltip.d.ts +2 -0
- package/web/tooltip.js +12 -0
- package/web/tour.d.ts +57 -0
- package/web/tour.js +450 -0
package/src/store.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { AttachmentStore } from "./attachments/store.ts";
|
|
3
|
+
import { AgentToolPermissionError, AgentToolPermissionStore, permissionOutcome, type AgentToolGrantAnswer, type AgentToolRequestInput, type AgentToolRequestRecord, type AnswerResult, type GrantEditInput, type GrantEditResult, type OwnerPrincipal } from "./agent/tool-permissions.ts";
|
|
4
|
+
import type { AgentToolMigrationCard } from "./agent/tool-permissions.ts";
|
|
5
|
+
import { SqliteAsideDecisions } from "./agent/aside-target-routes.ts";
|
|
6
|
+
import type { LegacyScopeItem } from "./agent/definition.ts";
|
|
2
7
|
import { say, type Text } from "./server-text.ts";
|
|
3
8
|
import { OMO_CHECK_BASELINE, omoCheckActionable, omoCheckDaytime } from "./omo-check-policy.ts";
|
|
4
9
|
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
|
@@ -6,8 +11,10 @@ import { join } from "node:path";
|
|
|
6
11
|
import { isDeepStrictEqual } from "node:util";
|
|
7
12
|
import type { ConversationInfo, DecisionAnswerRequest, NotificationMode, ReplyNotification } from "./protocol.ts";
|
|
8
13
|
import type { NativeTurnStats } from "./turn-stats.ts";
|
|
9
|
-
import { SecurityRuntimeError, securityDecision, type AllowedValue, type HeldState, type SecurityApprover, type SecurityHoldRecord, type SecurityIntent, type SecurityReceipt } from "./security-allow.ts";
|
|
14
|
+
import { SecurityRuntimeError, securityDecision, type AllowedValue, type HeldState, type SecurityApprover, type MessageSecurityHoldRecord as SecurityHoldRecord, type SecurityIntent, type SecurityReceipt, type ArtifactSecurityHold, type ArtifactReviewReceipt, type ArtifactReleaseReceipt, type ArtifactReadBinding, type ArtifactReviewSummary } from "./security-allow.ts";
|
|
15
|
+
import { CollaborationStoreError, collaborationDigest, agentRequestTerminal, agentRequestRetryable, AGENT_REQUEST_FINAL_SQL, collaborationNever, collaborationStageDecision, type CollaborationBinding, type AgentCancelReceipt, type CollaborationGate, type CollaborationTarget, type AgentRequestInput, type AgentRequest, type AgentActivation, type AgentReceipt, type AgentDelivery, type AgentResultInput, type AgentResult, type AgentArtifact, type AgentArtifactStage, type AgentStageInput, type ArtifactGrant, type CollaborationPermissionOutcome, type CollaborationIntent } from "./agent/collaboration-types.ts";
|
|
10
16
|
import { findPrivate, normalizeTerm, PUBLIC_ACCOUNT } from "./security/terms.ts";
|
|
17
|
+
import { testCrashPoint } from "./test-crash.ts";
|
|
11
18
|
|
|
12
19
|
export interface SecurityBaselineTerm {
|
|
13
20
|
readonly id: string;
|
|
@@ -35,7 +42,24 @@ export interface StoredEvent {
|
|
|
35
42
|
|
|
36
43
|
export type MainInputSource =
|
|
37
44
|
| { origin: "user"; input_ref: { message_seq: number } }
|
|
38
|
-
| { origin: "work_update"; input_ref: { update_ids: string[]; notification_id: string } }
|
|
45
|
+
| { origin: "work_update"; input_ref: { update_ids: string[]; notification_id: string } }
|
|
46
|
+
| { readonly origin: "agent_collaboration"; readonly input_ref: {
|
|
47
|
+
readonly request_id: string; readonly activation_id: string; readonly direction: AgentActivation["direction"]; readonly message_seq: number } }
|
|
48
|
+
| { readonly origin: "agent_tool_permission"; readonly input_ref: CollaborationPermissionOutcome & { readonly activation_id: string } }
|
|
49
|
+
| { readonly origin: "collaboration_artifact_read"; readonly input_ref: {
|
|
50
|
+
readonly holdId: string; readonly readOperationId: string; readonly continuationId: string; readonly reviewedScopeDigest: string } };
|
|
51
|
+
|
|
52
|
+
/** Preserve the discriminant when projecting a persisted input to a turn source. */
|
|
53
|
+
export function mainInputSource(input: MainInputSource): MainInputSource {
|
|
54
|
+
switch (input.origin) {
|
|
55
|
+
case "user": return { origin: input.origin, input_ref: input.input_ref };
|
|
56
|
+
case "work_update": return { origin: input.origin, input_ref: input.input_ref };
|
|
57
|
+
case "agent_collaboration": return { origin: input.origin, input_ref: input.input_ref };
|
|
58
|
+
case "agent_tool_permission": return { origin: input.origin, input_ref: input.input_ref };
|
|
59
|
+
case "collaboration_artifact_read": return { origin: input.origin, input_ref: input.input_ref };
|
|
60
|
+
default: return collaborationNever(input);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
39
63
|
|
|
40
64
|
export type MainInput = MainInputSource & {
|
|
41
65
|
session_path: string;
|
|
@@ -44,6 +68,7 @@ export type MainInput = MainInputSource & {
|
|
|
44
68
|
input_id?: string;
|
|
45
69
|
wire_message?: string;
|
|
46
70
|
native_identity?: boolean;
|
|
71
|
+
readonly authored_binding?: CollaborationBinding;
|
|
47
72
|
};
|
|
48
73
|
|
|
49
74
|
export interface MainTurn {
|
|
@@ -263,7 +288,7 @@ function terminalWork(status: WorkStatus): boolean {
|
|
|
263
288
|
export type DecisionMethod = "select" | "confirm" | "input" | "editor" | "question" | "unknown";
|
|
264
289
|
export type DecisionBinding = "live" | "expired" | "closed" | "unknown";
|
|
265
290
|
export type DecisionStatus = "pending" | "submitted" | "delivered" | "needs_review" | "closed";
|
|
266
|
-
export type DecisionRoute = "live_response" | "follow_up_command";
|
|
291
|
+
export type DecisionRoute = "live_response" | "follow_up_command" | "collaboration_stage";
|
|
267
292
|
|
|
268
293
|
export interface Decision {
|
|
269
294
|
readonly id: string;
|
|
@@ -353,13 +378,26 @@ export interface DecisionClaim {
|
|
|
353
378
|
/**
|
|
354
379
|
* A required question (no idle deadline) that its owner's turn, main's or a work's, is blocked on. Input queued behind
|
|
355
380
|
* that turn (main chat, or a work instruction sent as a steer) cannot reach the owner before the answer, so it does not
|
|
356
|
-
* change the question's context, and a recheck must not be queued there either.
|
|
381
|
+
* change the question's context, and a recheck must not be queued there either. Bug 236: a queued work instruction ends
|
|
382
|
+
* the wait instead (releasedWorkQuestion).
|
|
357
383
|
*/
|
|
358
384
|
export function blockedQuestion(decision: Pick<Decision, "method" | "permission" | "bindingState" | "deadlineAt">): boolean {
|
|
359
385
|
return decision.method === "question" && !decision.permission &&
|
|
360
386
|
decision.bindingState === "live" && decision.deadlineAt === null;
|
|
361
387
|
}
|
|
362
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Bug 236 (operator decision 2026-11-05): a work's required question whose native wait the gateway ended because a newer
|
|
391
|
+
* instruction was queued behind it. The question stays open in the inbox, takes its answer as a follow-up command, and a
|
|
392
|
+
* newer instruction does not change its context: the step that depends on the answer still waits for it.
|
|
393
|
+
*/
|
|
394
|
+
export const INSTRUCTION_RELEASED = "instruction_released";
|
|
395
|
+
|
|
396
|
+
export function releasedWorkQuestion(decision: Pick<Decision, "workId" | "method" | "permission" | "bindingState" | "reason">): boolean {
|
|
397
|
+
return decision.workId !== null && decision.method === "question" && !decision.permission &&
|
|
398
|
+
decision.bindingState === "closed" && decision.reason === INSTRUCTION_RELEASED;
|
|
399
|
+
}
|
|
400
|
+
|
|
363
401
|
/** blockedQuestion owned by main. */
|
|
364
402
|
export function blockedMainQuestion(decision: Pick<Decision, "workId" | "method" | "permission" | "bindingState" | "deadlineAt">): boolean {
|
|
365
403
|
return decision.workId === null && blockedQuestion(decision);
|
|
@@ -377,7 +415,7 @@ export interface DecisionDismissal {
|
|
|
377
415
|
}
|
|
378
416
|
|
|
379
417
|
export class DecisionStoreError extends Error {
|
|
380
|
-
constructor(readonly code: "security_endpoint_required" | "decision_not_found" | "decision_conflict" | "submission_conflict" | "source_conflict" | "invalid_decision" | "invalid_decision_answer" | "invalid_decision_cursor" | "invalid_decision_transition", readonly decision: Decision | null = null) {
|
|
418
|
+
constructor(readonly code: "security_endpoint_required" | "decision_not_found" | "decision_conflict" | "submission_conflict" | "source_conflict" | "invalid_decision" | "invalid_decision_answer" | "invalid_decision_cursor" | "invalid_decision_transition" | "tool_grant_response_required" | "tool_grant_principal_required", readonly decision: Decision | null = null) {
|
|
381
419
|
super(code);
|
|
382
420
|
}
|
|
383
421
|
}
|
|
@@ -445,7 +483,7 @@ export function replacementQuestionPayload(payload: Record<string, unknown>): Re
|
|
|
445
483
|
* async_wait_closed (a waitForAnswer false question whose native wait ended) and recheck_incomplete (a recheck turn
|
|
446
484
|
* ended without a replacement; the question stands as asked). A permission is never resumed (submitDecision, info).
|
|
447
485
|
*/
|
|
448
|
-
export const RESUMABLE_CLOSED: ReadonlySet<string> = new Set(["report_only", "main_released", "async_wait_closed", "recheck_incomplete"]);
|
|
486
|
+
export const RESUMABLE_CLOSED: ReadonlySet<string> = new Set(["report_only", "main_released", INSTRUCTION_RELEASED, "async_wait_closed", "recheck_incomplete"]);
|
|
449
487
|
|
|
450
488
|
/** Checks structural completeness, not the semantic appropriateness of a historical answer. */
|
|
451
489
|
function completeDecisionAnswer(method: DecisionMethod, payload: unknown, response: unknown): boolean {
|
|
@@ -478,19 +516,24 @@ function completeDecisionAnswer(method: DecisionMethod, payload: unknown, respon
|
|
|
478
516
|
}
|
|
479
517
|
}
|
|
480
518
|
|
|
519
|
+
// allow: SIZE_OK - this existing Store owns the shared SQLite writer; T2 explicitly owns only its scoped transaction hunks.
|
|
481
520
|
export class Store {
|
|
482
521
|
private readonly db: Database;
|
|
522
|
+
readonly attachments: AttachmentStore;
|
|
483
523
|
private readonly securityReceiptListeners = new Set<() => void>();
|
|
484
524
|
|
|
485
525
|
constructor(dataDir: string) {
|
|
486
526
|
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
487
527
|
chmodSync(dataDir, 0o700);
|
|
488
528
|
this.db = new Database(join(dataDir, "gateway.sqlite"), { create: true });
|
|
529
|
+
// Independent writers serialize admission instead of failing immediately during another short transaction.
|
|
530
|
+
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
489
531
|
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
490
532
|
// Linux builds of bun:sqlite default to synchronous=FULL, which fsyncs every appended event (~80x slower,
|
|
491
533
|
// and a stalled disk stalls the gateway). WAL + NORMAL cannot corrupt the database; a power cut may drop
|
|
492
534
|
// only the last few commits. This also matches the macOS default.
|
|
493
535
|
this.db.exec("PRAGMA synchronous = NORMAL;");
|
|
536
|
+
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
494
537
|
this.db.exec(`
|
|
495
538
|
CREATE TABLE IF NOT EXISTS conversations (
|
|
496
539
|
id TEXT PRIMARY KEY,
|
|
@@ -675,6 +718,14 @@ export class Store {
|
|
|
675
718
|
this.db.close();
|
|
676
719
|
throw error;
|
|
677
720
|
}
|
|
721
|
+
this.toolPermissions = new AgentToolPermissionStore(this.db);
|
|
722
|
+
this.asideTargets = new SqliteAsideDecisions(this.db, (id) => this.getDecision(id));
|
|
723
|
+
try {
|
|
724
|
+
this.toolPermissions.migrate();
|
|
725
|
+
} catch (error) {
|
|
726
|
+
this.db.close();
|
|
727
|
+
throw error;
|
|
728
|
+
}
|
|
678
729
|
this.db.exec(`
|
|
679
730
|
CREATE TABLE IF NOT EXISTS security_baseline_terms (
|
|
680
731
|
id TEXT PRIMARY KEY, value TEXT COLLATE BINARY NOT NULL UNIQUE, data TEXT NOT NULL
|
|
@@ -690,12 +741,1628 @@ export class Store {
|
|
|
690
741
|
id TEXT PRIMARY KEY, source_key TEXT UNIQUE, intent_submission TEXT UNIQUE, data TEXT NOT NULL
|
|
691
742
|
);
|
|
692
743
|
`);
|
|
744
|
+
this.attachments = new AttachmentStore(this.db, (conv, data, receipt) => this.acceptMainUser(conv, data, receipt));
|
|
745
|
+
try {
|
|
746
|
+
this.db.transaction(() => {
|
|
747
|
+
const schema = this.db.query<{ sql: string }, []>("SELECT sql FROM sqlite_master WHERE type='table' AND name='decisions'").get();
|
|
748
|
+
if (schema && !schema.sql.includes("'collaboration_stage'")) {
|
|
749
|
+
const indexes = this.db.query<{ sql: string }, []>("SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='decisions' AND sql IS NOT NULL").all();
|
|
750
|
+
this.db.exec("ALTER TABLE decisions RENAME TO collaboration_legacy_decisions");
|
|
751
|
+
this.db.exec(schema.sql.replace("'live_response','follow_up_command'", "'live_response','follow_up_command','collaboration_stage'"));
|
|
752
|
+
this.db.exec("INSERT INTO decisions SELECT * FROM collaboration_legacy_decisions; DROP TABLE collaboration_legacy_decisions");
|
|
753
|
+
for (const index of indexes) this.db.exec(index.sql);
|
|
754
|
+
}
|
|
755
|
+
// JSON is the typed record; generated columns cannot drift from its relational keys or revisions.
|
|
756
|
+
this.db.exec(`
|
|
757
|
+
CREATE TABLE IF NOT EXISTS agent_lifecycle (agent_id TEXT PRIMARY KEY, epoch INTEGER NOT NULL CHECK(epoch > 0));
|
|
758
|
+
CREATE TABLE IF NOT EXISTS agent_requests (
|
|
759
|
+
id TEXT PRIMARY KEY, data TEXT NOT NULL CHECK(json_valid(data)),
|
|
760
|
+
root_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.rootId')) STORED NOT NULL REFERENCES agent_requests(id),
|
|
761
|
+
parent_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.parentId')) STORED REFERENCES agent_requests(id),
|
|
762
|
+
requester_conv TEXT GENERATED ALWAYS AS (json_extract(data,'$.requesterConv')) STORED NOT NULL,
|
|
763
|
+
requester_agent TEXT GENERATED ALWAYS AS (json_extract(data,'$.requesterAgent')) STORED,
|
|
764
|
+
target_conv TEXT GENERATED ALWAYS AS (json_extract(data,'$.targetConv')) STORED NOT NULL,
|
|
765
|
+
target_agent TEXT GENERATED ALWAYS AS (json_extract(data,'$.targetAgent')) STORED NOT NULL,
|
|
766
|
+
kind TEXT GENERATED ALWAYS AS (json_extract(data,'$.kind')) STORED CHECK(kind IN ('task','question')),
|
|
767
|
+
payload_json TEXT GENERATED ALWAYS AS (json_extract(data,'$.payload')) STORED NOT NULL,
|
|
768
|
+
payload_sha TEXT GENERATED ALWAYS AS (json_extract(data,'$.payloadSha')) STORED NOT NULL,
|
|
769
|
+
submission_key TEXT GENERATED ALWAYS AS (json_extract(data,'$.submissionKey')) STORED NOT NULL UNIQUE,
|
|
770
|
+
fingerprint TEXT GENERATED ALWAYS AS (json_extract(data,'$.fingerprint')) STORED NOT NULL,
|
|
771
|
+
state TEXT GENERATED ALWAYS AS (json_extract(data,'$.state')) STORED NOT NULL CHECK(state IN ('queued','running','waiting_peer','waiting_owner','result_preparing','completed','failed','interrupted','cancel_requested','cancelled','unresolved_delivery','version_conflict')),
|
|
772
|
+
revision INTEGER GENERATED ALWAYS AS (json_extract(data,'$.revision')) STORED NOT NULL CHECK(revision > 0),
|
|
773
|
+
created_at TEXT GENERATED ALWAYS AS (json_extract(data,'$.createdAt')) STORED NOT NULL,
|
|
774
|
+
result_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.resultId')) STORED,
|
|
775
|
+
cancel_reason TEXT GENERATED ALWAYS AS (json_extract(data,'$.cancelReason')) STORED,
|
|
776
|
+
CHECK(id = json_extract(data,'$.id'))
|
|
777
|
+
);
|
|
778
|
+
CREATE INDEX IF NOT EXISTS agent_requests_target ON agent_requests(target_conv,state,created_at);
|
|
779
|
+
CREATE INDEX IF NOT EXISTS agent_requests_tree ON agent_requests(root_id,parent_id);
|
|
780
|
+
CREATE UNIQUE INDEX IF NOT EXISTS agent_requests_child ON agent_requests(parent_id) WHERE parent_id IS NOT NULL AND state NOT IN ${AGENT_REQUEST_FINAL_SQL};
|
|
781
|
+
CREATE TABLE IF NOT EXISTS agent_activations (
|
|
782
|
+
id TEXT PRIMARY KEY, data TEXT NOT NULL CHECK(json_valid(data)),
|
|
783
|
+
request_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.requestId')) STORED NOT NULL REFERENCES agent_requests(id),
|
|
784
|
+
direction TEXT GENERATED ALWAYS AS (json_extract(data,'$.direction')) STORED NOT NULL CHECK(direction IN ('request','reply','owner_answer')),
|
|
785
|
+
ordinal INTEGER GENERATED ALWAYS AS (json_extract(data,'$.ordinal')) STORED NOT NULL,
|
|
786
|
+
input_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.inputId')) STORED NOT NULL UNIQUE,
|
|
787
|
+
input_seq INTEGER GENERATED ALWAYS AS (json_extract(data,'$.inputSeq')) STORED REFERENCES events(seq),
|
|
788
|
+
target_conv TEXT GENERATED ALWAYS AS (json_extract(data,'$.targetConv')) STORED NOT NULL,
|
|
789
|
+
session_path TEXT GENERATED ALWAYS AS (json_extract(data,'$.binding.sessionPath')) STORED,
|
|
790
|
+
durable_session_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.binding.durableSessionId')) STORED,
|
|
791
|
+
gateway_generation TEXT GENERATED ALWAYS AS (json_extract(data,'$.binding.gatewayGeneration')) STORED,
|
|
792
|
+
definition_sha TEXT GENERATED ALWAYS AS (json_extract(data,'$.binding.definitionSha')) STORED,
|
|
793
|
+
state TEXT GENERATED ALWAYS AS (json_extract(data,'$.state')) STORED NOT NULL CHECK(state IN ('queued','attempted','acknowledged','consumed','rejected','unknown')),
|
|
794
|
+
revision INTEGER GENERATED ALWAYS AS (json_extract(data,'$.revision')) STORED NOT NULL CHECK(revision > 0),
|
|
795
|
+
consumed_entry TEXT GENERATED ALWAYS AS (json_extract(data,'$.consumedEntry')) STORED,
|
|
796
|
+
quiesced INTEGER GENERATED ALWAYS AS (json_extract(data,'$.quiesced')) STORED NOT NULL,
|
|
797
|
+
UNIQUE(request_id,direction,ordinal), CHECK(id = json_extract(data,'$.id'))
|
|
798
|
+
);
|
|
799
|
+
CREATE TABLE IF NOT EXISTS agent_results (
|
|
800
|
+
id TEXT PRIMARY KEY, data TEXT NOT NULL CHECK(json_valid(data)),
|
|
801
|
+
request_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.requestId')) STORED NOT NULL UNIQUE REFERENCES agent_requests(id),
|
|
802
|
+
activation_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.activationId')) STORED NOT NULL REFERENCES agent_activations(id),
|
|
803
|
+
submission_key TEXT GENERATED ALWAYS AS (json_extract(data,'$.submissionKey')) STORED NOT NULL UNIQUE,
|
|
804
|
+
payload_sha TEXT GENERATED ALWAYS AS (json_extract(data,'$.payloadSha')) STORED NOT NULL,
|
|
805
|
+
text TEXT GENERATED ALWAYS AS (json_extract(data,'$.text')) STORED NOT NULL,
|
|
806
|
+
outcome TEXT GENERATED ALWAYS AS (json_extract(data,'$.outcome')) STORED CHECK(outcome IN ('completed','failed')),
|
|
807
|
+
manifest_json TEXT GENERATED ALWAYS AS (json_extract(data,'$.manifest')) STORED NOT NULL,
|
|
808
|
+
committed_at TEXT GENERATED ALWAYS AS (json_extract(data,'$.committedAt')) STORED NOT NULL
|
|
809
|
+
);
|
|
810
|
+
CREATE TABLE IF NOT EXISTS agent_artifacts (
|
|
811
|
+
id TEXT PRIMARY KEY, data TEXT NOT NULL CHECK(json_valid(data)),
|
|
812
|
+
producer_conv TEXT GENERATED ALWAYS AS (json_extract(data,'$.producerConv')) STORED NOT NULL,
|
|
813
|
+
request_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.requestId')) STORED REFERENCES agent_requests(id),
|
|
814
|
+
sha256 TEXT GENERATED ALWAYS AS (json_extract(data,'$.sha256')) STORED NOT NULL,
|
|
815
|
+
bytes INTEGER GENERATED ALWAYS AS (json_extract(data,'$.bytes')) STORED NOT NULL CHECK(bytes BETWEEN 0 AND 10485760),
|
|
816
|
+
media_type TEXT GENERATED ALWAYS AS (json_extract(data,'$.mediaType')) STORED NOT NULL,
|
|
817
|
+
original_name TEXT GENERATED ALWAYS AS (json_extract(data,'$.originalName')) STORED NOT NULL,
|
|
818
|
+
sealed_name TEXT GENERATED ALWAYS AS (json_extract(data,'$.sealedName')) STORED NOT NULL,
|
|
819
|
+
state TEXT GENERATED ALWAYS AS (json_extract(data,'$.state')) STORED CHECK(state IN ('staged','committed','trashed')),
|
|
820
|
+
lifecycle_epoch INTEGER GENERATED ALWAYS AS (json_extract(data,'$.lifecycleEpoch')) STORED NOT NULL,
|
|
821
|
+
storage_class TEXT GENERATED ALWAYS AS (json_extract(data,'$.storageClass')) STORED CHECK(storage_class IN ('source_copy','scoped_document')),
|
|
822
|
+
stage_operation_key TEXT GENERATED ALWAYS AS (json_extract(data,'$.stageOperationKey')) STORED NOT NULL UNIQUE,
|
|
823
|
+
stage_payload_sha TEXT GENERATED ALWAYS AS (json_extract(data,'$.stagePayloadSha')) STORED NOT NULL,
|
|
824
|
+
provenance_json TEXT GENERATED ALWAYS AS (json_extract(data,'$.provenance')) STORED NOT NULL
|
|
825
|
+
);
|
|
826
|
+
CREATE TABLE IF NOT EXISTS agent_artifact_grants (
|
|
827
|
+
grant_id TEXT PRIMARY KEY, data TEXT NOT NULL CHECK(json_valid(data)),
|
|
828
|
+
artifact_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.artifactId')) STORED NOT NULL REFERENCES agent_artifacts(id),
|
|
829
|
+
grantee_conv TEXT GENERATED ALWAYS AS (json_extract(data,'$.granteeConv')) STORED NOT NULL,
|
|
830
|
+
request_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.requestId')) STORED NOT NULL REFERENCES agent_requests(id),
|
|
831
|
+
permission TEXT GENERATED ALWAYS AS (json_extract(data,'$.permission')) STORED CHECK(permission IN ('read','transfer')),
|
|
832
|
+
revoked INTEGER GENERATED ALWAYS AS (json_extract(data,'$.revoked')) STORED NOT NULL CHECK(revoked IN (0,1)),
|
|
833
|
+
revision INTEGER GENERATED ALWAYS AS (json_extract(data,'$.revision')) STORED NOT NULL CHECK(revision > 0),
|
|
834
|
+
grantee_lifecycle_epoch INTEGER GENERATED ALWAYS AS (json_extract(data,'$.granteeLifecycleEpoch')) STORED NOT NULL,
|
|
835
|
+
recipient_authority_epoch INTEGER GENERATED ALWAYS AS (json_extract(data,'$.recipientAuthorityEpoch')) STORED,
|
|
836
|
+
UNIQUE(artifact_id,grantee_conv,request_id,permission)
|
|
837
|
+
);
|
|
838
|
+
CREATE TABLE IF NOT EXISTS agent_artifact_stages (
|
|
839
|
+
transfer_id TEXT PRIMARY KEY, data TEXT NOT NULL CHECK(json_valid(data)),
|
|
840
|
+
operation_key TEXT GENERATED ALWAYS AS (json_extract(data,'$.operationKey')) STORED NOT NULL UNIQUE,
|
|
841
|
+
payload_sha TEXT GENERATED ALWAYS AS (json_extract(data,'$.payloadSha')) STORED NOT NULL,
|
|
842
|
+
source_ref_json TEXT GENERATED ALWAYS AS (json_extract(data,'$.source')) STORED NOT NULL,
|
|
843
|
+
target_ref_json TEXT GENERATED ALWAYS AS (json_extract(data,'$.target')) STORED NOT NULL,
|
|
844
|
+
scope_digest TEXT GENERATED ALWAYS AS (json_extract(data,'$.scopeDigest')) STORED NOT NULL,
|
|
845
|
+
owner_principal_ref TEXT GENERATED ALWAYS AS (json_extract(data,'$.ownerPrincipalRef')) STORED NOT NULL,
|
|
846
|
+
source_input_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.sourceBinding.inputId')) STORED NOT NULL,
|
|
847
|
+
source_activation_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.sourceBinding.activationId')) STORED,
|
|
848
|
+
origin_request_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.originRequestId')) STORED REFERENCES agent_requests(id),
|
|
849
|
+
decision_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.decisionId')) STORED UNIQUE REFERENCES decisions(id),
|
|
850
|
+
decision_revision INTEGER GENERATED ALWAYS AS (json_extract(data,'$.decisionRevision')) STORED,
|
|
851
|
+
continuation_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.continuationId')) STORED NOT NULL UNIQUE,
|
|
852
|
+
state TEXT GENERATED ALWAYS AS (json_extract(data,'$.state')) STORED CHECK(state IN ('awaiting_transfer_authority','ready','staging','staged','denied','cancelled','invalidated','unknown')),
|
|
853
|
+
revision INTEGER GENERATED ALWAYS AS (json_extract(data,'$.revision')) STORED NOT NULL CHECK(revision > 0),
|
|
854
|
+
artifact_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.artifactId')) STORED UNIQUE REFERENCES agent_artifacts(id),
|
|
855
|
+
authority_ref TEXT GENERATED ALWAYS AS (json_extract(data,'$.authority')) STORED,
|
|
856
|
+
export_receipt TEXT GENERATED ALWAYS AS (json_extract(data,'$.exportReceipt')) STORED,
|
|
857
|
+
error_code TEXT GENERATED ALWAYS AS (json_extract(data,'$.errorCode')) STORED
|
|
858
|
+
);
|
|
859
|
+
CREATE TABLE IF NOT EXISTS agent_question_links (
|
|
860
|
+
decision_id TEXT PRIMARY KEY REFERENCES decisions(id), request_id TEXT REFERENCES agent_requests(id),
|
|
861
|
+
activation_id TEXT REFERENCES agent_activations(id), transfer_id TEXT REFERENCES agent_artifact_stages(transfer_id),
|
|
862
|
+
read_operation_id TEXT, origin_input_seq INTEGER REFERENCES events(seq)
|
|
863
|
+
);
|
|
864
|
+
CREATE UNIQUE INDEX IF NOT EXISTS agent_collaboration_operation ON events(json_extract(data,'$.key')) WHERE type='agent.collaboration.operation';
|
|
865
|
+
`);
|
|
866
|
+
// An index built with an older final-state list is rebuilt; the current list only frees more rows, so it cannot fail.
|
|
867
|
+
const child = this.db.query<{ sql: string }, []>("SELECT sql FROM sqlite_master WHERE type='index' AND name='agent_requests_child'").get();
|
|
868
|
+
if (child && !child.sql.includes(`state NOT IN ${AGENT_REQUEST_FINAL_SQL}`)) {
|
|
869
|
+
this.db.exec(`DROP INDEX agent_requests_child; CREATE UNIQUE INDEX agent_requests_child ON agent_requests(parent_id) WHERE parent_id IS NOT NULL AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}`);
|
|
870
|
+
}
|
|
871
|
+
}).immediate();
|
|
872
|
+
this.attachments.migrate();
|
|
873
|
+
} catch (error) {
|
|
874
|
+
this.db.close();
|
|
875
|
+
throw error;
|
|
876
|
+
}
|
|
877
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
693
878
|
for (const suffix of ["", "-wal", "-shm"]) {
|
|
694
879
|
const path = join(dataDir, `gateway.sqlite${suffix}`);
|
|
695
880
|
if (existsSync(path)) chmodSync(path, 0o600);
|
|
696
881
|
}
|
|
697
882
|
}
|
|
698
883
|
|
|
884
|
+
ensureAgentLifecycle(agentId: string): number {
|
|
885
|
+
this.db.query("INSERT OR IGNORE INTO agent_lifecycle VALUES (?, 1)").run(agentId);
|
|
886
|
+
const row = this.db.query<{ epoch: number }, [string]>("SELECT epoch FROM agent_lifecycle WHERE agent_id=?").get(agentId);
|
|
887
|
+
if (!row) throw new CollaborationStoreError("agent_not_found", 404);
|
|
888
|
+
return row.epoch;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** A cancelled request's undispatched inputs are definitely unsent: they close as rejected and never dispatch. */
|
|
892
|
+
private closeQueuedAgentInputs(requestId: string): void {
|
|
893
|
+
for (const row of this.db.query<{ data: string }, [string]>("SELECT data FROM agent_activations WHERE request_id=? AND state='queued'").all(requestId)) {
|
|
894
|
+
const activation: AgentActivation = JSON.parse(row.data);
|
|
895
|
+
if (activation.direction === "reply") continue;
|
|
896
|
+
this.writeAgentActivation({ ...activation, state: "rejected", quiesced: true, revision: activation.revision + 1 }, activation.revision);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* cancel_requested becomes cancelled only when its own executions are quiescent and every child is terminal; the
|
|
902
|
+
* check then repeats for the parent that waited on it. Anything still uncertain stays cancel_requested.
|
|
903
|
+
*/
|
|
904
|
+
private settleCancelledAgentRequest(requestId: string): void {
|
|
905
|
+
let current = this.getAgentRequest(requestId);
|
|
906
|
+
while (current && current.state === "cancel_requested") {
|
|
907
|
+
const id = current.id;
|
|
908
|
+
const running = this.db.query<{ data: string }, []>("SELECT data FROM agent_activations WHERE state NOT IN ('queued','rejected') AND quiesced=0").all()
|
|
909
|
+
.some(row => this.agentExecutionRequest(JSON.parse(row.data) as AgentActivation)?.id === id);
|
|
910
|
+
if (running || this.db.query(`SELECT id FROM agent_requests WHERE parent_id=? AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}`).get(id)) return;
|
|
911
|
+
this.replyWithoutResult(this.writeAgentRequest({ ...current, state: "cancelled", revision: current.revision + 1 }, current.revision), current.cancelReason ?? "cancelled");
|
|
912
|
+
current = current.parentId ? this.getAgentRequest(current.parentId) : null;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Executions to interrupt cooperatively for requests left in cancel_requested: conversation and activation of each. */
|
|
917
|
+
cancelRequestedExecutions(): readonly { readonly conv: string; readonly activationId: string }[] {
|
|
918
|
+
return this.db.query<{ data: string }, []>("SELECT data FROM agent_activations WHERE state IN ('attempted','acknowledged','consumed') AND quiesced=0").all()
|
|
919
|
+
.map(row => JSON.parse(row.data) as AgentActivation)
|
|
920
|
+
.filter(activation => this.agentExecutionRequest(activation)?.state === "cancel_requested")
|
|
921
|
+
.map(activation => ({ conv: activation.targetConv, activationId: activation.id }));
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Delete waits while the conversation owes or holds collaboration work: open requests, undelivered inputs or uncertain runs. */
|
|
925
|
+
agentCollaborationBusy(convId: string): boolean {
|
|
926
|
+
return this.db.query(`SELECT 1 FROM agent_requests WHERE (requester_conv=?1 OR target_conv=?1)
|
|
927
|
+
AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}
|
|
928
|
+
UNION ALL SELECT 1 FROM agent_activations WHERE target_conv=?1 AND quiesced=0 AND state IN ('queued','attempted','acknowledged','consumed','unknown') LIMIT 1`).get(convId) !== null;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* After the native connection was lost, an input that crossed the RPC boundary without consumption evidence is
|
|
933
|
+
* uncertain. It is never replayed: the activation becomes unknown and its request unresolved_delivery, which only an
|
|
934
|
+
* evidence recheck may resolve.
|
|
935
|
+
*/
|
|
936
|
+
markUncertainAgentDeliveries(): number {
|
|
937
|
+
return this.db.transaction(() => {
|
|
938
|
+
let marked = 0;
|
|
939
|
+
for (const row of this.db.query<{ data: string }, []>("SELECT data FROM agent_activations WHERE state IN ('attempted','acknowledged') AND quiesced=0").all()) {
|
|
940
|
+
const activation: AgentActivation = JSON.parse(row.data);
|
|
941
|
+
this.writeAgentActivation({ ...activation, state: "unknown", revision: activation.revision + 1 }, activation.revision);
|
|
942
|
+
const request = this.agentExecutionRequest(activation) ?? this.getAgentRequest(activation.requestId);
|
|
943
|
+
if (request && !agentRequestTerminal(request.state) && request.state !== "cancel_requested") {
|
|
944
|
+
this.writeAgentRequest({ ...request, state: "unresolved_delivery", revision: request.revision + 1 }, request.revision);
|
|
945
|
+
}
|
|
946
|
+
this.appendEvent(activation.targetConv, "agent.collaboration.delivery_unknown", { activationId: activation.id, requestId: request?.id ?? null });
|
|
947
|
+
marked += 1;
|
|
948
|
+
}
|
|
949
|
+
return marked;
|
|
950
|
+
}).immediate();
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
advanceAgentLifecycle(agentId: string, expectedEpoch: number): number {
|
|
954
|
+
if (this.db.query("UPDATE agent_lifecycle SET epoch=epoch+1 WHERE agent_id=? AND epoch=?").run(agentId, expectedEpoch).changes !== 1) {
|
|
955
|
+
throw new CollaborationStoreError("lifecycle_conflict");
|
|
956
|
+
}
|
|
957
|
+
return expectedEpoch + 1;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** The Hub records its current binding, not a model-supplied identity. No session is opened here. */
|
|
961
|
+
recordAgentBinding(binding: CollaborationBinding): void {
|
|
962
|
+
this.db.transaction(() => {
|
|
963
|
+
const current = this.agentBinding(binding.convId);
|
|
964
|
+
const conv = this.getConversation(binding.convId);
|
|
965
|
+
if (!conv || conv.threadId !== binding.sessionPath || (conv.agentId ?? null) !== binding.agentId ||
|
|
966
|
+
(current && current.ownerInputRevision > binding.ownerInputRevision)) throw new CollaborationStoreError("binding_conflict");
|
|
967
|
+
if (binding.agentId !== null && this.ensureAgentLifecycle(binding.agentId) !== binding.lifecycleEpoch) throw new CollaborationStoreError("lifecycle_conflict");
|
|
968
|
+
if (current && isDeepStrictEqual(current, binding)) return;
|
|
969
|
+
if (current) {
|
|
970
|
+
if (current.activationId !== null && binding.activationId === null && binding.ownerInputRevision > current.ownerInputRevision) {
|
|
971
|
+
const activation = this.getAgentActivation(current.activationId);
|
|
972
|
+
const request = activation && this.agentExecutionRequest(activation);
|
|
973
|
+
if (request && !agentRequestTerminal(request.state) && request.state !== "cancel_requested") {
|
|
974
|
+
this.replyWithoutResult(this.writeAgentRequest({ ...request, state: "interrupted", revision: request.revision + 1 }, request.revision), "owner_input");
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
for (const row of this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE json_extract(data,'$.source')='collaboration_artifact' AND json_extract(data,'$.readScope.granteeConv')=?").all(binding.convId)) {
|
|
978
|
+
const hold: ArtifactSecurityHold = JSON.parse(row.data);
|
|
979
|
+
const intendedRead = binding.readContinuationId === hold.continuation.id && hold.continuation.state === "queued" &&
|
|
980
|
+
binding.ownerInputRevision === hold.continuation.originOwnerInputRevision;
|
|
981
|
+
if (!intendedRead && hold.continuation.releaseReceiptId === null && hold.state !== "invalidated") {
|
|
982
|
+
this.writeArtifactHold({ ...hold, revision: hold.revision + 1, state: "invalidated", invalidationReason: "binding_changed",
|
|
983
|
+
continuation: { ...hold.continuation, state: "invalidated" } });
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
for (const row of this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifact_stages WHERE json_extract(data,'$.sourceBinding.convId')=? AND state IN ('awaiting_transfer_authority','ready','staging','staged')").all(binding.convId)) {
|
|
987
|
+
const stage: AgentArtifactStage = JSON.parse(row.data);
|
|
988
|
+
if (stage.artifactId && this.agentArtifact(stage.artifactId)?.requestId !== null) continue;
|
|
989
|
+
this.db.query("UPDATE agent_artifact_stages SET data=? WHERE transfer_id=?").run(JSON.stringify({ ...stage,
|
|
990
|
+
state: stage.state === "staging" ? "unknown" : "invalidated", revision: stage.revision + 1, errorCode: "binding_changed" }), stage.transferId);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
this.appendEvent(binding.convId, "agent.collaboration.binding", binding);
|
|
994
|
+
}).immediate();
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
agentBinding(conv: string): CollaborationBinding | null {
|
|
998
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM events WHERE conv=? AND type='agent.collaboration.binding' ORDER BY seq DESC LIMIT 1").get(conv);
|
|
999
|
+
return row ? JSON.parse(row.data) : null;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
private assertAgentBinding(binding: CollaborationBinding): void {
|
|
1003
|
+
if (!isDeepStrictEqual(this.agentBinding(binding.convId), binding) || this.getConversation(binding.convId)?.threadId !== binding.sessionPath) throw new CollaborationStoreError("binding_conflict");
|
|
1004
|
+
if (binding.agentId !== null && this.ensureAgentLifecycle(binding.agentId) !== binding.lifecycleEpoch) throw new CollaborationStoreError("lifecycle_conflict");
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
private agentTarget(agentId: string): CollaborationTarget {
|
|
1008
|
+
const conv = this.db.query<{ id: string }, [string]>("SELECT id FROM conversations WHERE agent_id=? AND hidden=0").get(agentId);
|
|
1009
|
+
if (!conv) throw new CollaborationStoreError("agent_not_found", 404);
|
|
1010
|
+
return { agentId, convId: conv.id, lifecycleEpoch: this.ensureAgentLifecycle(agentId) };
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
private checkAgentAccess(gate: CollaborationGate, binding: CollaborationBinding, target: CollaborationTarget): number | undefined {
|
|
1014
|
+
this.assertAgentBinding(binding);
|
|
1015
|
+
if (!isDeepStrictEqual(this.agentTarget(target.agentId), target)) throw new CollaborationStoreError("lifecycle_conflict");
|
|
1016
|
+
gate.assertAccess(this.db, binding, target);
|
|
1017
|
+
switch (gate.mode) {
|
|
1018
|
+
case "standalone": return undefined;
|
|
1019
|
+
case "authority": {
|
|
1020
|
+
for (const actor of [target, ...(binding.agentId === null ? [] : [{ ...binding, agentId: binding.agentId }])]) {
|
|
1021
|
+
const access = gate.access(this.db, actor.agentId);
|
|
1022
|
+
if (access.accessState !== "open") throw new CollaborationStoreError("agent_access_blocked", 403);
|
|
1023
|
+
if (access.lifecycleEpoch !== actor.lifecycleEpoch) throw new CollaborationStoreError("lifecycle_conflict");
|
|
1024
|
+
}
|
|
1025
|
+
return gate.access(this.db, target.agentId).authorityEpoch;
|
|
1026
|
+
}
|
|
1027
|
+
default: return collaborationNever(gate);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
getAgentRequest(id: string): AgentRequest | null {
|
|
1032
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_requests WHERE id=?").get(id);
|
|
1033
|
+
return row ? JSON.parse(row.data) : null;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
getAgentActivation(id: string): AgentActivation | null {
|
|
1037
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_activations WHERE id=?").get(id);
|
|
1038
|
+
return row ? JSON.parse(row.data) : null;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
agentActivations(requestId: string): AgentActivation[] {
|
|
1042
|
+
return this.db.query<{ data: string }, [string]>("SELECT data FROM agent_activations WHERE request_id=? ORDER BY ordinal").all(requestId).map(row => JSON.parse(row.data));
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** Durable FIFO, excluding only requests whose own dependency is blocked. */
|
|
1046
|
+
nextAgentInput(conv: string): { readonly activation: AgentActivation; readonly request: AgentRequest } | null {
|
|
1047
|
+
const rows = this.db.query<{ data: string }, [string]>(
|
|
1048
|
+
"SELECT data FROM agent_activations WHERE target_conv=? AND state='queued' ORDER BY json_extract(data,'$.inputSeq'),id").all(conv);
|
|
1049
|
+
for (const row of rows) {
|
|
1050
|
+
const activation: AgentActivation = JSON.parse(row.data);
|
|
1051
|
+
const request = this.getAgentRequest(activation.requestId);
|
|
1052
|
+
const execution = this.agentExecutionRequest(activation);
|
|
1053
|
+
// A reply reports its child's final state, cancelled included; only its execution (the parent) can stop it.
|
|
1054
|
+
if (!request || (activation.direction !== "reply" && (request.state === "cancel_requested" || request.state === "cancelled")) ||
|
|
1055
|
+
this.agentRequestBlocked(request.id) || (activation.direction === "request" && request.state !== "queued") ||
|
|
1056
|
+
(execution && (agentRequestTerminal(execution.state) || execution.state === "cancel_requested" || this.agentRequestBlocked(execution.id)))) continue;
|
|
1057
|
+
return { activation, request };
|
|
1058
|
+
}
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** A definite pre-dispatch denial is not unknown delivery and must not block independent FIFO work. */
|
|
1063
|
+
rejectQueuedAgentInput(activationId: string, code: string): void {
|
|
1064
|
+
this.db.transaction(() => {
|
|
1065
|
+
const activation = this.getAgentActivation(activationId);
|
|
1066
|
+
if (!activation || activation.state !== "queued") return;
|
|
1067
|
+
this.writeAgentActivation({ ...activation, state: "rejected", quiesced: true, revision: activation.revision + 1 }, activation.revision);
|
|
1068
|
+
const request = this.agentExecutionRequest(activation);
|
|
1069
|
+
if (request && !agentRequestTerminal(request.state) && request.state !== "cancel_requested") {
|
|
1070
|
+
this.replyWithoutResult(this.writeAgentRequest({ ...request, state: code === "version_conflict" ? "version_conflict" : "interrupted", revision: request.revision + 1 }, request.revision), code);
|
|
1071
|
+
}
|
|
1072
|
+
this.appendEvent(activation.targetConv, "agent.collaboration.rejected", { activationId, code });
|
|
1073
|
+
}).immediate();
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** The binding, claim and common input journal cross one writer boundary before the RPC write. */
|
|
1077
|
+
admitAgentInput(input: { readonly activationId: string; readonly activationRevision: number; readonly requestRevision: number;
|
|
1078
|
+
readonly previousBinding: CollaborationBinding | null; readonly wireMessage: string; readonly nativeIdentity: boolean }, binding: CollaborationBinding, gate: CollaborationGate): StoredEvent {
|
|
1079
|
+
return this.db.transaction(() => {
|
|
1080
|
+
const next = this.nextAgentInput(binding.convId);
|
|
1081
|
+
if (!next || next.activation.id !== input.activationId || next.activation.revision !== input.activationRevision ||
|
|
1082
|
+
next.request.revision !== input.requestRevision) throw new CollaborationStoreError("activation_revision_conflict");
|
|
1083
|
+
if (!isDeepStrictEqual(this.agentBinding(binding.convId), input.previousBinding)) throw new CollaborationStoreError("binding_conflict");
|
|
1084
|
+
if (this.lastSeq(binding.convId, "message.user") !== binding.ownerInputRevision ||
|
|
1085
|
+
this.pendingMainUsers(binding.convId).length > 0 || this.mainTurn(binding.convId)) throw new CollaborationStoreError("conversation_busy");
|
|
1086
|
+
const queued = this.getEvent(next.activation.inputSeq);
|
|
1087
|
+
if (!queued) throw new CollaborationStoreError("input_source_missing");
|
|
1088
|
+
const intent: CollaborationIntent = JSON.parse(JSON.stringify(queued.data));
|
|
1089
|
+
if (intent.origin === "agent_tool_permission") {
|
|
1090
|
+
const original = this.getAgentActivation(intent.outcome.blockedActivationId)?.binding;
|
|
1091
|
+
if (!original || original.ownerInputRevision !== binding.ownerInputRevision) throw new CollaborationStoreError("permission_outcome_conflict");
|
|
1092
|
+
this.assertCollaborationPermission(intent.outcome, original, gate);
|
|
1093
|
+
}
|
|
1094
|
+
this.recordAgentBinding(binding);
|
|
1095
|
+
const activation = this.claimAgentActivation({ activationId: next.activation.id, expectedRevision: input.activationRevision,
|
|
1096
|
+
requestRevision: input.requestRevision }, binding, gate);
|
|
1097
|
+
let source: MainInputSource;
|
|
1098
|
+
switch (intent.origin) {
|
|
1099
|
+
case "agent_collaboration": source = { origin: intent.origin, input_ref: { request_id: intent.requestId,
|
|
1100
|
+
activation_id: activation.id, direction: intent.direction, message_seq: intent.messageSeq } }; break;
|
|
1101
|
+
case "agent_tool_permission": source = { origin: intent.origin, input_ref: { ...intent.outcome, activation_id: activation.id } }; break;
|
|
1102
|
+
case "collaboration_artifact_read": case "collaboration_stage": throw new CollaborationStoreError("input_source_mismatch");
|
|
1103
|
+
default: return collaborationNever(intent);
|
|
1104
|
+
}
|
|
1105
|
+
const journal = this.appendEvent(binding.convId, "main.input", { ...source, session_path: binding.sessionPath,
|
|
1106
|
+
handoff: null, input_id: activation.inputId, wire_message: input.wireMessage, native_identity: input.nativeIdentity });
|
|
1107
|
+
this.appendEvent(binding.convId, "agent.collaboration.admitted", { activationId: activation.id, intentSeq: activation.inputSeq, inputSeq: journal.seq });
|
|
1108
|
+
this.writeAgentActivation({ ...activation, inputSeq: journal.seq, revision: activation.revision + 1 }, activation.revision);
|
|
1109
|
+
return journal;
|
|
1110
|
+
}).immediate();
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** Delivery correlation stays on the child; a reply executes only its parent, never that completed child. */
|
|
1114
|
+
private agentExecutionRequest(activation: AgentActivation): AgentRequest | null {
|
|
1115
|
+
const correlated = this.getAgentRequest(activation.requestId);
|
|
1116
|
+
if (!correlated) throw new CollaborationStoreError("request_not_found", 404);
|
|
1117
|
+
switch (activation.direction) {
|
|
1118
|
+
case "request": case "owner_answer": return correlated;
|
|
1119
|
+
case "reply": return correlated.parentId === null ? null : this.getAgentRequest(correlated.parentId);
|
|
1120
|
+
default: return collaborationNever(activation.direction);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
private agentOperation<T>(key: string, payload: unknown): T | null {
|
|
1125
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM events WHERE type='agent.collaboration.operation' AND json_extract(data,'$.key')=?").get(key);
|
|
1126
|
+
if (!row) return null;
|
|
1127
|
+
const operation: { payloadSha: string; receipt: T } = JSON.parse(row.data);
|
|
1128
|
+
if (operation.payloadSha !== collaborationDigest(payload)) throw new CollaborationStoreError("submission_conflict");
|
|
1129
|
+
return operation.receipt;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
private saveAgentOperation<T>(conv: string, operation: { readonly key: string; readonly payload: unknown; readonly receipt: T }): T {
|
|
1133
|
+
this.appendEvent(conv, "agent.collaboration.operation", { key: operation.key, payloadSha: collaborationDigest(operation.payload), receipt: operation.receipt });
|
|
1134
|
+
return operation.receipt;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
private writeAgentRequest(request: AgentRequest, expectedRevision: number): AgentRequest {
|
|
1138
|
+
if (this.db.query("UPDATE agent_requests SET data=? WHERE id=? AND revision=?").run(JSON.stringify(request), request.id, expectedRevision).changes !== 1) throw new CollaborationStoreError("request_revision_conflict");
|
|
1139
|
+
this.queueCollaborationEvent(this.appendEvent(request.requesterConv, "agent.collaboration.status", { requestId: request.id, state: request.state, revision: request.revision }));
|
|
1140
|
+
return request;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* F3 D1: the stored collaboration events an open page must see as they happen - every request state change (the
|
|
1145
|
+
* requester's `agent.collaboration.status`) and the two reply messages of a committed result - wait here for the Hub's
|
|
1146
|
+
* publication. The Hub takes them at its own points (admission, result finalization, the reply wake) and, through
|
|
1147
|
+
* onCollaborationEvents, one microtask after any other transaction, so no caller has to know which Store write moved a
|
|
1148
|
+
* request. The microtask runs after the transaction ended: a rolled-back write leaves no event to publish.
|
|
1149
|
+
*/
|
|
1150
|
+
private readonly collaborationOutbox = new Set<number>();
|
|
1151
|
+
private collaborationFlushPending = false;
|
|
1152
|
+
/** Set by the Hub; called once per tick after transactions queued collaboration events. close() clears it. */
|
|
1153
|
+
onCollaborationEvents: (() => void) | null = null;
|
|
1154
|
+
|
|
1155
|
+
private queueCollaborationEvent(event: StoredEvent): void {
|
|
1156
|
+
this.collaborationOutbox.add(event.seq);
|
|
1157
|
+
if (this.collaborationFlushPending) return;
|
|
1158
|
+
this.collaborationFlushPending = true;
|
|
1159
|
+
queueMicrotask(() => {
|
|
1160
|
+
this.collaborationFlushPending = false;
|
|
1161
|
+
this.onCollaborationEvents?.();
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/** The queued status and reply events, oldest first, each once; one whose transaction rolled back is gone. close() empties the queue. */
|
|
1166
|
+
takeCollaborationEvents(): StoredEvent[] {
|
|
1167
|
+
const seqs = [...this.collaborationOutbox].sort((a, b) => a - b);
|
|
1168
|
+
this.collaborationOutbox.clear();
|
|
1169
|
+
return seqs.flatMap(seq => {
|
|
1170
|
+
const event = this.getEvent(seq);
|
|
1171
|
+
return event && (event.type === "agent.collaboration.status" || event.type === "message.collaboration") ? [event] : [];
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* A request that became final without a result still reaches its requester once, on the same reply path as a result:
|
|
1177
|
+
* its final state and reason, never text or files. A parent that is itself final or being cancelled gets nothing.
|
|
1178
|
+
*/
|
|
1179
|
+
private replyWithoutResult(request: AgentRequest, reason: string): void {
|
|
1180
|
+
const parent = request.parentId === null ? null : this.getAgentRequest(request.parentId);
|
|
1181
|
+
if (parent && (agentRequestTerminal(parent.state) || parent.state === "cancel_requested")) return;
|
|
1182
|
+
const message = this.appendEvent(request.requesterConv, "agent.collaboration.final_reply", { requestId: request.id, rootId: request.rootId,
|
|
1183
|
+
direction: "reply", trust: "delegated", state: request.state, reason, resultId: null, manifest: [] });
|
|
1184
|
+
this.newAgentActivation(request, "reply", message.seq);
|
|
1185
|
+
this.finalReplyOutbox.add(message.seq);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
private finalReplyOutbox = new Set<number>();
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* F4-2 (C-4): the result-less final replies appended since the last call, oldest first, each once; every path that appends
|
|
1192
|
+
* one then wakes the replies, where the Hub sends their fallback notices. A reply whose transaction rolled back is gone.
|
|
1193
|
+
*/
|
|
1194
|
+
takeFinalReplies(): StoredEvent[] {
|
|
1195
|
+
const seqs = [...this.finalReplyOutbox].sort((a, b) => a - b);
|
|
1196
|
+
this.finalReplyOutbox.clear();
|
|
1197
|
+
return seqs.flatMap(seq => {
|
|
1198
|
+
const event = this.getEvent(seq);
|
|
1199
|
+
return event?.type === "agent.collaboration.final_reply" ? [event] : [];
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/** Conversations owed a queued reply; dispatch itself keeps the owner's turns first. */
|
|
1204
|
+
queuedAgentReplyConvs(): string[] {
|
|
1205
|
+
return this.db.query<{ conv: string }, []>("SELECT DISTINCT target_conv conv FROM agent_activations WHERE state='queued' AND direction='reply'").all().map(row => row.conv);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
private writeAgentActivation(activation: AgentActivation, expectedRevision: number): AgentActivation {
|
|
1209
|
+
if (this.db.query("UPDATE agent_activations SET data=? WHERE id=? AND revision=?").run(JSON.stringify(activation), activation.id, expectedRevision).changes !== 1) throw new CollaborationStoreError("activation_revision_conflict");
|
|
1210
|
+
return activation;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
private newAgentActivation(request: AgentRequest, direction: AgentActivation["direction"], source: number | { readonly seq: number }): AgentActivation {
|
|
1214
|
+
const id = crypto.randomUUID();
|
|
1215
|
+
const targetConv = direction === "reply" ? request.requesterConv : request.targetConv;
|
|
1216
|
+
const ordinal = this.db.query<{ n: number }, [string, string]>("SELECT COALESCE(max(ordinal),0)+1 n FROM agent_activations WHERE request_id=? AND direction=?").get(request.id, direction)?.n ?? 1;
|
|
1217
|
+
const input = typeof source === "number" ? this.appendEvent(targetConv, "agent.collaboration.input", {
|
|
1218
|
+
origin: "agent_collaboration", requestId: request.id, activationId: id, direction, messageSeq: source } satisfies CollaborationIntent) : source;
|
|
1219
|
+
const activation: AgentActivation = { id, requestId: request.id, direction, ordinal, inputId: crypto.randomUUID(), inputSeq: input.seq, targetConv, binding: null, state: "queued", revision: 1, consumedEntry: null, quiesced: false };
|
|
1220
|
+
this.db.query("INSERT INTO agent_activations(id,data) VALUES (?,?)").run(id, JSON.stringify(activation));
|
|
1221
|
+
return activation;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
acceptAgentRequest(input: AgentRequestInput, binding: CollaborationBinding, gate: CollaborationGate): AgentReceipt {
|
|
1225
|
+
return this.db.transaction(() => {
|
|
1226
|
+
if (binding.readContinuationId !== undefined) throw new CollaborationStoreError("read_only_continuation", 403);
|
|
1227
|
+
if (!input.toolCallId || !input.text.trim() || !["task", "question"].includes(input.kind) ||
|
|
1228
|
+
new Set(input.files).size !== input.files.length) throw new CollaborationStoreError("invalid_request", 400);
|
|
1229
|
+
if (Buffer.byteLength(input.text) > 65536 || input.files.length > 10) throw new CollaborationStoreError("request_too_large", 413);
|
|
1230
|
+
const key = JSON.stringify([binding.durableSessionId, input.toolCallId, "request"]);
|
|
1231
|
+
const payload = { ...input, text: input.text.trim(), files: [...input.files].sort() };
|
|
1232
|
+
const operationPayload = { payload, source: binding.convId, activationId: binding.activationId };
|
|
1233
|
+
const prior = this.agentOperation<AgentReceipt>(key, operationPayload);
|
|
1234
|
+
if (prior) return prior;
|
|
1235
|
+
const target = this.agentTarget(input.targetAgentId);
|
|
1236
|
+
const authorityEpoch = this.checkAgentAccess(gate, binding, target);
|
|
1237
|
+
if (binding.agentId !== null && input.kind !== "question") throw new CollaborationStoreError("agent_question_only", 403);
|
|
1238
|
+
if (binding.agentId === target.agentId) throw new CollaborationStoreError("cycle_detected");
|
|
1239
|
+
let parent: AgentRequest | null = null;
|
|
1240
|
+
if (binding.activationId !== null) {
|
|
1241
|
+
const active = this.getAgentActivation(binding.activationId);
|
|
1242
|
+
if (!active || active.state !== "consumed" || !isDeepStrictEqual(active.binding, binding)) throw new CollaborationStoreError("parent_conflict");
|
|
1243
|
+
parent = this.agentExecutionRequest(active);
|
|
1244
|
+
if (!parent || parent.targetConv !== binding.convId || binding.lifecycleEpoch !== parent.targetLifecycleEpoch ||
|
|
1245
|
+
!["running", "waiting_peer"].includes(parent.state) || this.agentRequestBlocked(parent.id)) throw new CollaborationStoreError("parent_conflict");
|
|
1246
|
+
const ancestors = this.db.query<{ requester_agent: string | null; target_agent: string }, [string]>(`
|
|
1247
|
+
WITH RECURSIVE ancestry AS (SELECT * FROM agent_requests WHERE id=? UNION ALL SELECT p.* FROM agent_requests p JOIN ancestry c ON c.parent_id=p.id)
|
|
1248
|
+
SELECT requester_agent,target_agent FROM ancestry`).all(parent.id);
|
|
1249
|
+
if (ancestors.some(row => row.requester_agent === target.agentId || row.target_agent === target.agentId)) throw new CollaborationStoreError("cycle_detected");
|
|
1250
|
+
}
|
|
1251
|
+
const id = crypto.randomUUID();
|
|
1252
|
+
const rootId = parent?.rootId ?? id;
|
|
1253
|
+
const artifacts = input.files.map(artifactId => this.agentArtifact(artifactId));
|
|
1254
|
+
if (artifacts.some(artifact => artifact === null)) throw new CollaborationStoreError("artifact_not_found", 404);
|
|
1255
|
+
const digests = artifacts.map(artifact => artifact?.sha256).sort();
|
|
1256
|
+
const fingerprint = collaborationDigest([rootId, target.agentId, input.kind, input.text.normalize("NFC").replace(/\r\n/g, "\n").trim(), digests]);
|
|
1257
|
+
const duplicateRow = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_requests WHERE fingerprint=? ORDER BY created_at DESC LIMIT 1").get(fingerprint);
|
|
1258
|
+
if (duplicateRow) {
|
|
1259
|
+
const duplicate: AgentRequest = JSON.parse(duplicateRow.data);
|
|
1260
|
+
if (!agentRequestRetryable(duplicate.state)) return this.saveAgentOperation(binding.convId, { key, payload: operationPayload, receipt: { requestId: duplicate.id, state: duplicate.state, revision: duplicate.revision } });
|
|
1261
|
+
if (binding.agentId !== null || input.retryOf !== duplicate.id) throw new CollaborationStoreError("semantic_retry_required");
|
|
1262
|
+
}
|
|
1263
|
+
if (input.retryOf) {
|
|
1264
|
+
const original = this.getAgentRequest(input.retryOf);
|
|
1265
|
+
if (binding.agentId !== null || !original || !agentRequestRetryable(original.state) || original.requesterConv !== binding.convId) throw new CollaborationStoreError("invalid_retry");
|
|
1266
|
+
}
|
|
1267
|
+
if (parent && this.db.query(`SELECT id FROM agent_requests WHERE parent_id=? AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}`).get(parent.id)) throw new CollaborationStoreError("unfinished_child");
|
|
1268
|
+
const request: AgentRequest = { id, rootId, parentId: parent?.id ?? null, requesterConv: binding.convId, requesterAgent: binding.agentId, targetConv: target.convId, targetAgent: target.agentId,
|
|
1269
|
+
requesterLifecycleEpoch: binding.lifecycleEpoch, targetLifecycleEpoch: target.lifecycleEpoch,
|
|
1270
|
+
...(authorityEpoch === undefined ? {} : { recipientAuthorityEpoch: authorityEpoch }),
|
|
1271
|
+
...(gate.mode === "authority" && binding.agentId !== null ? { requesterAuthorityEpoch: gate.access(this.db, binding.agentId).authorityEpoch } : {}),
|
|
1272
|
+
kind: input.kind, payload, payloadSha: collaborationDigest({ payload, parentId: parent?.id ?? null, rootId, digests }), submissionKey: key, fingerprint, state: "queued", revision: 1, createdAt: new Date().toISOString(), resultId: null, cancelReason: null };
|
|
1273
|
+
for (const artifact of artifacts) {
|
|
1274
|
+
if (!artifact || artifact.state !== "staged" || artifact.requestId !== null || artifact.producerConv !== binding.convId) throw new CollaborationStoreError("artifact_already_bound");
|
|
1275
|
+
const stage = this.agentStageByOperation(artifact.stageOperationKey);
|
|
1276
|
+
if (!stage || stage.state !== "staged" || stage.artifactId !== artifact.id || stage.target.convId !== target.convId || artifact.sha256 !== this.stageDigest(stage)) throw new CollaborationStoreError("transfer_scope_changed");
|
|
1277
|
+
this.assertAgentStage(stage, gate);
|
|
1278
|
+
}
|
|
1279
|
+
this.db.query("INSERT INTO agent_requests(id,data) VALUES (?,?)").run(id, JSON.stringify(request));
|
|
1280
|
+
for (const artifact of artifacts) {
|
|
1281
|
+
if (!artifact) throw new CollaborationStoreError("artifact_not_found", 404);
|
|
1282
|
+
const committed: AgentArtifact = { ...artifact, requestId: id, state: "committed", provenance: { ...artifact.provenance, requestId: id, rootId } };
|
|
1283
|
+
this.db.query("UPDATE agent_artifacts SET data=? WHERE id=? AND request_id IS NULL").run(JSON.stringify(committed), artifact.id);
|
|
1284
|
+
this.createAgentGrant(committed, { convId: target.convId, lifecycleEpoch: target.lifecycleEpoch, authorityEpoch });
|
|
1285
|
+
}
|
|
1286
|
+
const data = { requestId: id, rootId, direction: "request", text: payload.text, files: input.files, trust: "delegated",
|
|
1287
|
+
src: { channel: "gateway", adapter: "agent-collaboration" }, actor: { kind: binding.agentId === null ? "main" : "agent", id: binding.agentId } };
|
|
1288
|
+
this.appendEvent(binding.convId, "message.collaboration", { ...data, outgoing: true });
|
|
1289
|
+
const message = this.appendEvent(target.convId, "message.collaboration", { ...data, outgoing: false });
|
|
1290
|
+
this.newAgentActivation(request, "request", message.seq);
|
|
1291
|
+
if (parent) this.writeAgentRequest({ ...parent, state: "waiting_peer", revision: parent.revision + 1 }, parent.revision);
|
|
1292
|
+
const receipt = this.saveAgentOperation(binding.convId, { key, payload: operationPayload, receipt: { requestId: id, state: request.state, revision: request.revision } });
|
|
1293
|
+
testCrashPoint("collaboration.admission.before-commit");
|
|
1294
|
+
return receipt;
|
|
1295
|
+
}).immediate();
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
agentArtifact(id: string): AgentArtifact | null {
|
|
1299
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifacts WHERE id=?").get(id);
|
|
1300
|
+
return row ? JSON.parse(row.data) : null;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
agentGrant(id: string): ArtifactGrant | null {
|
|
1304
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifact_grants WHERE grant_id=?").get(id);
|
|
1305
|
+
return row ? JSON.parse(row.data) : null;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
agentStage(transferId: string): AgentArtifactStage | null {
|
|
1309
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifact_stages WHERE transfer_id=?").get(transferId);
|
|
1310
|
+
return row ? JSON.parse(row.data) : null;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
private agentStageByOperation(operationKey: string): AgentArtifactStage | null {
|
|
1314
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifact_stages WHERE operation_key=?").get(operationKey);
|
|
1315
|
+
return row ? JSON.parse(row.data) : null;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
private stageDigest(stage: AgentArtifactStage): string {
|
|
1319
|
+
switch (stage.source.kind) {
|
|
1320
|
+
case "agent_path": case "artifact": return stage.source.sha256;
|
|
1321
|
+
case "attachment": return stage.source.origin.originalSha256;
|
|
1322
|
+
default: return collaborationNever(stage.source);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
private assertAgentStage(stage: AgentArtifactStage, gate: CollaborationGate): void {
|
|
1327
|
+
const targetEpoch = this.checkAgentAccess(gate, stage.sourceBinding, stage.target);
|
|
1328
|
+
if (stage.targetAuthorityEpoch !== targetEpoch || (gate.mode === "authority" && stage.sourceBinding.agentId !== null &&
|
|
1329
|
+
stage.sourceAuthorityEpoch !== gate.access(this.db, stage.sourceBinding.agentId).authorityEpoch)) throw new CollaborationStoreError("authority_epoch_mismatch");
|
|
1330
|
+
if (stage.originRequestId !== null) {
|
|
1331
|
+
const request = this.getAgentRequest(stage.originRequestId);
|
|
1332
|
+
if (!request || agentRequestTerminal(request.state) || request.state === "cancel_requested" ||
|
|
1333
|
+
this.agentRequestBlocked(request.id, stage.decisionId ?? "")) throw new CollaborationStoreError("transfer_scope_changed");
|
|
1334
|
+
}
|
|
1335
|
+
if (stage.sourceBinding.activationId !== null) {
|
|
1336
|
+
const activation = this.getAgentActivation(stage.sourceBinding.activationId);
|
|
1337
|
+
if (!activation || activation.state !== "consumed" || !isDeepStrictEqual(activation.binding, stage.sourceBinding) ||
|
|
1338
|
+
this.agentExecutionRequest(activation)?.id !== (stage.originRequestId ?? undefined)) throw new CollaborationStoreError("transfer_scope_changed");
|
|
1339
|
+
}
|
|
1340
|
+
switch (stage.source.kind) {
|
|
1341
|
+
case "agent_path":
|
|
1342
|
+
if (stage.originRequestId !== null && stage.source.path.split(/[\\/]/)[0] === "outputs" &&
|
|
1343
|
+
this.deriveOutputLineage(stage.originRequestId).storageClass === "scoped_document") throw new CollaborationStoreError("scoped_path_requires_artifact", 403);
|
|
1344
|
+
return;
|
|
1345
|
+
case "attachment":
|
|
1346
|
+
if (!gate.assertSelectedExportEligible) throw new CollaborationStoreError("attachment_eligibility_unavailable", 503);
|
|
1347
|
+
gate.assertSelectedExportEligible(this.db, stage.source.origin);
|
|
1348
|
+
break;
|
|
1349
|
+
case "artifact": {
|
|
1350
|
+
const grant = this.agentGrant(stage.source.grantId);
|
|
1351
|
+
const artifact = this.agentArtifact(stage.source.artifactId);
|
|
1352
|
+
if (!grant || grant.revoked || !artifact || artifact.state === "trashed") throw new CollaborationStoreError("source_revoked", 410);
|
|
1353
|
+
if (grant.requestId !== stage.source.requestId || grant.artifactId !== artifact.id || grant.revision !== stage.source.grantRevision ||
|
|
1354
|
+
grant.granteeConv !== stage.sourceBinding.convId || grant.granteeLifecycleEpoch !== stage.sourceBinding.lifecycleEpoch ||
|
|
1355
|
+
artifact.sha256 !== stage.source.sha256) throw new CollaborationStoreError("transfer_scope_changed");
|
|
1356
|
+
if (gate.mode === "authority" && stage.sourceBinding.agentId !== null &&
|
|
1357
|
+
grant.recipientAuthorityEpoch !== gate.access(this.db, stage.sourceBinding.agentId).authorityEpoch) throw new CollaborationStoreError("authority_epoch_mismatch");
|
|
1358
|
+
break;
|
|
1359
|
+
}
|
|
1360
|
+
default: return collaborationNever(stage.source);
|
|
1361
|
+
}
|
|
1362
|
+
if (stage.state === "awaiting_transfer_authority") return;
|
|
1363
|
+
if (!stage.authority || stage.authority.scopeDigest !== stage.scopeDigest) throw new CollaborationStoreError("transfer_authority_required", 403);
|
|
1364
|
+
switch (stage.authority.kind) {
|
|
1365
|
+
case "owner_decision": {
|
|
1366
|
+
const decision = this.getDecision(stage.authority.decisionId);
|
|
1367
|
+
if (!decision || decision.revision !== stage.authority.decisionRevision || decision.submissionId !== stage.authority.submissionId ||
|
|
1368
|
+
stage.authority.transferId !== stage.transferId || decision.status !== "delivered") throw new CollaborationStoreError("transfer_scope_changed");
|
|
1369
|
+
break;
|
|
1370
|
+
}
|
|
1371
|
+
case "tool_policy":
|
|
1372
|
+
if (gate.mode !== "authority" || !gate.assertTransferAuthority) throw new CollaborationStoreError("transfer_authority_unavailable", 503);
|
|
1373
|
+
gate.assertTransferAuthority(this.db, stage);
|
|
1374
|
+
break;
|
|
1375
|
+
default: return collaborationNever(stage.authority);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
beginAgentStage(input: AgentStageInput, binding: CollaborationBinding, gate: CollaborationGate): AgentArtifactStage {
|
|
1380
|
+
return this.db.transaction(() => {
|
|
1381
|
+
if (binding.readContinuationId !== undefined) throw new CollaborationStoreError("read_only_continuation", 403);
|
|
1382
|
+
const payloadSha = collaborationDigest({ input, binding });
|
|
1383
|
+
const existing = this.agentStageByOperation(input.operationKey);
|
|
1384
|
+
if (existing) {
|
|
1385
|
+
if (existing.payloadSha !== payloadSha) throw new CollaborationStoreError("submission_conflict");
|
|
1386
|
+
return existing;
|
|
1387
|
+
}
|
|
1388
|
+
const targetAuthorityEpoch = this.checkAgentAccess(gate, binding, input.target);
|
|
1389
|
+
const transferId = input.transferId ?? crypto.randomUUID();
|
|
1390
|
+
const scopeDigest = collaborationDigest({ source: input.source, target: input.target, binding, operationKey: input.operationKey, transferId, purpose: "collaboration_attachment_transfer" });
|
|
1391
|
+
const needsAuthority = input.source.kind !== "agent_path" && input.authority === null;
|
|
1392
|
+
const decision = needsAuthority ? this.createDecision({
|
|
1393
|
+
ownerConv: binding.convId, scope: `conv:${binding.convId}`, sourceSessionPath: null, sourceRequestId: transferId,
|
|
1394
|
+
method: "confirm", required: true, bindingState: "closed",
|
|
1395
|
+
payload: { kind: "collaboration-transfer", purpose: "collaboration_attachment_transfer", transferId, scopeDigest },
|
|
1396
|
+
}) : null;
|
|
1397
|
+
const origin = binding.activationId === null ? null : this.getAgentActivation(binding.activationId);
|
|
1398
|
+
const originRequest = origin === null ? null : this.agentExecutionRequest(origin);
|
|
1399
|
+
const stage: AgentArtifactStage = { ...input, transferId, payloadSha, scopeDigest, sourceBinding: binding, originRequestId: originRequest?.id ?? null,
|
|
1400
|
+
...(targetAuthorityEpoch === undefined ? {} : { targetAuthorityEpoch }),
|
|
1401
|
+
...(gate.mode === "authority" && binding.agentId !== null ? { sourceAuthorityEpoch: gate.access(this.db, binding.agentId).authorityEpoch } : {}),
|
|
1402
|
+
decisionId: decision?.id ?? null, decisionRevision: decision?.revision ?? null, continuationId: crypto.randomUUID(),
|
|
1403
|
+
state: needsAuthority ? "awaiting_transfer_authority" : "ready", revision: 1, artifactId: null, exportReceipt: null, errorCode: null };
|
|
1404
|
+
this.assertAgentStage(stage, gate);
|
|
1405
|
+
this.db.query("INSERT INTO agent_artifact_stages(transfer_id,data) VALUES (?,?)").run(transferId, JSON.stringify(stage));
|
|
1406
|
+
if (decision) this.db.query("INSERT INTO agent_question_links(decision_id,request_id,activation_id,transfer_id) VALUES (?,?,?,?)").run(decision.id, stage.originRequestId, binding.activationId, transferId);
|
|
1407
|
+
return stage;
|
|
1408
|
+
}).immediate();
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
answerAgentStage(input: { readonly transferId: string; readonly expectedRevision: number; readonly submissionId: string; readonly scopeDigest: string; readonly choice: "allow" | "deny" | "cancel"; readonly ownerPrincipalRef: string }, gate: CollaborationGate): AgentArtifactStage {
|
|
1412
|
+
return this.db.transaction(() => {
|
|
1413
|
+
const stage = this.agentStage(input.transferId);
|
|
1414
|
+
if (!stage || !stage.decisionId) throw new CollaborationStoreError("stage_not_found", 404);
|
|
1415
|
+
if (stage.ownerPrincipalRef !== input.ownerPrincipalRef) throw new CollaborationStoreError("owner_mismatch", 403);
|
|
1416
|
+
const key = JSON.stringify(["stage-answer", input.ownerPrincipalRef, input.submissionId]);
|
|
1417
|
+
const prior = this.agentOperation<AgentArtifactStage>(key, input);
|
|
1418
|
+
if (prior) return prior;
|
|
1419
|
+
const decision = this.getDecision(stage.decisionId);
|
|
1420
|
+
if (!decision || decision.revision !== input.expectedRevision || decision.submissionId !== null ||
|
|
1421
|
+
stage.state !== "awaiting_transfer_authority" || stage.scopeDigest !== input.scopeDigest) throw new CollaborationStoreError("transfer_scope_changed");
|
|
1422
|
+
this.checkAgentAccess(gate, stage.sourceBinding, stage.target);
|
|
1423
|
+
let state: AgentArtifactStage["state"];
|
|
1424
|
+
switch (input.choice) {
|
|
1425
|
+
case "allow": state = "ready"; break;
|
|
1426
|
+
case "deny": state = "denied"; break;
|
|
1427
|
+
case "cancel": state = "cancelled"; break;
|
|
1428
|
+
default: return collaborationNever(input.choice);
|
|
1429
|
+
}
|
|
1430
|
+
const decisionRevision = decision.revision + 1;
|
|
1431
|
+
const next: AgentArtifactStage = { ...stage, state, revision: stage.revision + 1, decisionRevision,
|
|
1432
|
+
authority: input.choice === "allow" ? { kind: "owner_decision", decisionId: decision.id, decisionRevision, submissionId: input.submissionId, scopeDigest: stage.scopeDigest, transferId: stage.transferId } : null,
|
|
1433
|
+
errorCode: input.choice === "allow" ? null : input.choice === "deny" ? "transfer_denied" : "transfer_cancelled" };
|
|
1434
|
+
this.db.query("UPDATE decisions SET revision=?,submission_id=?,submission_revision=?,submission_mode='answer',answer=?,answer_route='collaboration_stage',status=?,updated_at=? WHERE id=?")
|
|
1435
|
+
.run(decisionRevision, input.submissionId, input.expectedRevision, JSON.stringify({ collaborationTransfer: { choice: input.choice, transferId: stage.transferId, scopeDigest: stage.scopeDigest } }), input.choice === "allow" ? "delivered" : "closed", new Date().toISOString(), decision.id);
|
|
1436
|
+
if (input.choice === "allow") {
|
|
1437
|
+
this.assertAgentStage(next, gate);
|
|
1438
|
+
const intent: CollaborationIntent = { origin: "collaboration_stage", transferId: stage.transferId, continuationId: stage.continuationId };
|
|
1439
|
+
this.appendEvent(stage.sourceBinding.convId, "agent.collaboration.input", intent);
|
|
1440
|
+
}
|
|
1441
|
+
this.db.query("UPDATE agent_artifact_stages SET data=? WHERE transfer_id=? AND revision=?").run(JSON.stringify(next), stage.transferId, stage.revision);
|
|
1442
|
+
this.appendEvent(stage.sourceBinding.convId, "decision.operation", { decisionId: decision.id, request: { expectedRevision: input.expectedRevision, submissionId: input.submissionId, mode: "answer", response: { collaborationTransfer: { choice: input.choice, transferId: stage.transferId, scopeDigest: stage.scopeDigest } } }, targetScope: decision.scope, commandId: null, mainInputSeq: null });
|
|
1443
|
+
return this.saveAgentOperation(stage.sourceBinding.convId, { key, payload: input, receipt: next });
|
|
1444
|
+
}).immediate();
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
claimAgentStage(transferId: string, expectedRevision: number, gate: CollaborationGate): AgentArtifactStage {
|
|
1448
|
+
return this.db.transaction(() => {
|
|
1449
|
+
const stage = this.agentStage(transferId);
|
|
1450
|
+
if (!stage || stage.revision !== expectedRevision || stage.state !== "ready") throw new CollaborationStoreError("stage_conflict");
|
|
1451
|
+
this.assertAgentStage(stage, gate);
|
|
1452
|
+
const next: AgentArtifactStage = { ...stage, revision: stage.revision + 1, state: "staging" };
|
|
1453
|
+
this.db.query("UPDATE agent_artifact_stages SET data=? WHERE transfer_id=? AND revision=?").run(JSON.stringify(next), transferId, expectedRevision);
|
|
1454
|
+
return next;
|
|
1455
|
+
}).immediate();
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
sealAgentStage(input: { readonly transferId: string; readonly expectedRevision: number; readonly sha256: string; readonly bytes: number; readonly mediaType: string; readonly name: string; readonly sealedName: string }, gate: CollaborationGate): AgentArtifact {
|
|
1459
|
+
return this.db.transaction(() => {
|
|
1460
|
+
const stage = this.agentStage(input.transferId);
|
|
1461
|
+
if (!stage || stage.state !== "staging" || stage.revision !== input.expectedRevision) throw new CollaborationStoreError("stage_conflict");
|
|
1462
|
+
this.assertAgentStage(stage, gate);
|
|
1463
|
+
if (!/^[a-f0-9]{64}$/.test(input.sha256) || input.sha256 !== this.stageDigest(stage) ||
|
|
1464
|
+
!Number.isSafeInteger(input.bytes) || input.bytes < 0 || input.bytes > 10485760) throw new CollaborationStoreError("artifact_digest_conflict");
|
|
1465
|
+
const sourceArtifact = stage.source.kind === "artifact" ? this.agentArtifact(stage.source.artifactId) : null;
|
|
1466
|
+
const artifact: AgentArtifact = { id: crypto.randomUUID(), producerConv: stage.sourceBinding.convId, requestId: null, sha256: input.sha256, bytes: input.bytes,
|
|
1467
|
+
mediaType: input.mediaType, originalName: input.name, sealedName: input.sealedName, state: "staged", lifecycleEpoch: stage.sourceBinding.lifecycleEpoch,
|
|
1468
|
+
storageClass: stage.source.kind === "agent_path" ? "source_copy" : "scoped_document", stageOperationKey: stage.operationKey, stagePayloadSha: stage.payloadSha,
|
|
1469
|
+
provenance: { transferId: stage.transferId, transferAuthorityRef: stage.authority, targetAgentId: stage.target.agentId, targetConvId: stage.target.convId, targetLifecycleEpoch: stage.target.lifecycleEpoch,
|
|
1470
|
+
sourceInputId: stage.sourceBinding.inputId, sourceActivationId: stage.sourceBinding.activationId, requestId: null, rootId: null,
|
|
1471
|
+
...(stage.source.kind === "attachment" ? { originAttachment: stage.source.origin } : {}),
|
|
1472
|
+
...(sourceArtifact && stage.source.kind === "artifact" ? { ...sourceArtifact.provenance.originAttachment ? { originAttachment: sourceArtifact.provenance.originAttachment } : {},
|
|
1473
|
+
sourceArtifactId: sourceArtifact.id, sourceArtifactGrantRevision: stage.source.grantRevision,
|
|
1474
|
+
...(sourceArtifact.provenance.transferId ? { parentTransferId: sourceArtifact.provenance.transferId } : {}) } : {}),
|
|
1475
|
+
} };
|
|
1476
|
+
this.db.query("INSERT INTO agent_artifacts(id,data) VALUES (?,?)").run(artifact.id, JSON.stringify(artifact));
|
|
1477
|
+
const next: AgentArtifactStage = { ...stage, state: "staged", revision: stage.revision + 1, artifactId: artifact.id, exportReceipt: stage.source.kind === "attachment" ? stage.source.origin : null };
|
|
1478
|
+
this.db.query("UPDATE agent_artifact_stages SET data=? WHERE transfer_id=? AND revision=?").run(JSON.stringify(next), stage.transferId, stage.revision);
|
|
1479
|
+
return artifact;
|
|
1480
|
+
}).immediate();
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
private createAgentGrant(artifact: AgentArtifact, recipient: { readonly convId: string; readonly lifecycleEpoch: number; readonly authorityEpoch: number | undefined }): ArtifactGrant {
|
|
1484
|
+
if (artifact.requestId === null) throw new CollaborationStoreError("artifact_unbound");
|
|
1485
|
+
const grant: ArtifactGrant = { grantId: crypto.randomUUID(), artifactId: artifact.id, requestId: artifact.requestId, granteeConv: recipient.convId,
|
|
1486
|
+
permission: "read", revoked: false, revision: 1, granteeLifecycleEpoch: recipient.lifecycleEpoch,
|
|
1487
|
+
...(recipient.authorityEpoch === undefined ? {} : { recipientAuthorityEpoch: recipient.authorityEpoch }) };
|
|
1488
|
+
this.db.query("INSERT INTO agent_artifact_grants(grant_id,data) VALUES (?,?)").run(grant.grantId, JSON.stringify(grant));
|
|
1489
|
+
return grant;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
claimAgentActivation(input: { readonly activationId: string; readonly expectedRevision: number; readonly requestRevision: number }, binding: CollaborationBinding, gate: CollaborationGate): AgentActivation {
|
|
1493
|
+
return this.db.transaction(() => {
|
|
1494
|
+
const activation = this.getAgentActivation(input.activationId);
|
|
1495
|
+
const request = activation ? this.getAgentRequest(activation.requestId) : null;
|
|
1496
|
+
if (!activation || !request || activation.state !== "queued" || activation.revision !== input.expectedRevision || request.revision !== input.requestRevision) throw new CollaborationStoreError("activation_revision_conflict");
|
|
1497
|
+
this.assertAgentBinding(binding);
|
|
1498
|
+
if (binding.convId !== activation.targetConv || binding.activationId !== activation.id || binding.inputId !== activation.inputId) throw new CollaborationStoreError("binding_conflict");
|
|
1499
|
+
const expectedEpoch = activation.direction === "reply" ? request.requesterLifecycleEpoch : request.targetLifecycleEpoch;
|
|
1500
|
+
if (binding.lifecycleEpoch !== expectedEpoch) throw new CollaborationStoreError("lifecycle_conflict");
|
|
1501
|
+
if (gate.mode === "authority" && binding.agentId !== null) {
|
|
1502
|
+
const epoch = activation.direction === "reply" ? request.requesterAuthorityEpoch : request.recipientAuthorityEpoch;
|
|
1503
|
+
if (gate.access(this.db, binding.agentId).authorityEpoch !== epoch) throw new CollaborationStoreError("authority_epoch_mismatch");
|
|
1504
|
+
}
|
|
1505
|
+
if ((activation.direction !== "reply" && (request.state === "cancel_requested" || request.state === "cancelled")) || this.agentRequestBlocked(request.id)) throw new CollaborationStoreError("request_blocked");
|
|
1506
|
+
if (activation.direction === "request" && request.state !== "queued") throw new CollaborationStoreError("request_blocked");
|
|
1507
|
+
const execution = this.agentExecutionRequest(activation);
|
|
1508
|
+
if (execution && (agentRequestTerminal(execution.state) || execution.state === "cancel_requested" ||
|
|
1509
|
+
this.agentRequestBlocked(execution.id))) throw new CollaborationStoreError("request_blocked");
|
|
1510
|
+
// A started execution continues only on the definition it started with; a re-applied one is never reinterpreted.
|
|
1511
|
+
const origin = execution && activation.direction !== "request" ?
|
|
1512
|
+
this.agentActivations(execution.id).find(row => row.direction === "request")?.binding : undefined;
|
|
1513
|
+
if (origin && origin.definitionSha !== binding.definitionSha) throw new CollaborationStoreError("version_conflict", 409);
|
|
1514
|
+
const target = this.agentTarget(request.targetAgent);
|
|
1515
|
+
this.checkAgentAccess(gate, binding, target);
|
|
1516
|
+
if (activation.direction === "request") for (const artifactId of request.payload.files) {
|
|
1517
|
+
const grant = this.agentArtifactGrant(artifactId, binding.convId, request.id);
|
|
1518
|
+
if (!grant) throw new CollaborationStoreError("artifact_forbidden", 403);
|
|
1519
|
+
this.assertArtifactGrant(grant, binding, gate);
|
|
1520
|
+
}
|
|
1521
|
+
if (this.db.query("SELECT id FROM agent_activations WHERE target_conv=? AND id<>? AND state IN ('attempted','acknowledged','consumed','unknown') AND quiesced=0").get(binding.convId, activation.id)) throw new CollaborationStoreError("conversation_busy");
|
|
1522
|
+
const next = this.writeAgentActivation({ ...activation, binding, state: "attempted", revision: activation.revision + 1 }, activation.revision);
|
|
1523
|
+
if (activation.direction === "request" || (activation.direction === "owner_answer" && request.state === "waiting_owner")) {
|
|
1524
|
+
this.writeAgentRequest({ ...request, state: "running", revision: request.revision + 1 }, request.revision);
|
|
1525
|
+
}
|
|
1526
|
+
return next;
|
|
1527
|
+
}).immediate();
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
recordAgentActivationEvidence(input: { readonly activationId: string; readonly expectedRevision: number; readonly state: Exclude<AgentDelivery, "queued" | "attempted">; readonly consumedEntry?: string }, binding: CollaborationBinding): AgentActivation {
|
|
1531
|
+
return this.db.transaction(() => {
|
|
1532
|
+
const activation = this.getAgentActivation(input.activationId);
|
|
1533
|
+
if (!activation || !isDeepStrictEqual(activation.binding, binding)) throw new CollaborationStoreError("binding_conflict");
|
|
1534
|
+
if (activation.state === "consumed") return activation;
|
|
1535
|
+
if (activation.revision !== input.expectedRevision || activation.state === "queued" || activation.state === "rejected") throw new CollaborationStoreError("activation_revision_conflict");
|
|
1536
|
+
if (input.state === "consumed" && !input.consumedEntry) throw new CollaborationStoreError("consumption_evidence_required", 400);
|
|
1537
|
+
if (input.state === "unknown" && activation.state === "acknowledged") return activation;
|
|
1538
|
+
const next = this.writeAgentActivation({ ...activation, state: input.state, consumedEntry: input.consumedEntry ?? activation.consumedEntry, revision: activation.revision + 1 }, activation.revision);
|
|
1539
|
+
const execution = this.agentExecutionRequest(activation);
|
|
1540
|
+
const current = isDeepStrictEqual(this.agentBinding(binding.convId), binding) &&
|
|
1541
|
+
this.getConversation(binding.convId)?.threadId === binding.sessionPath &&
|
|
1542
|
+
(binding.agentId === null || this.ensureAgentLifecycle(binding.agentId) === binding.lifecycleEpoch);
|
|
1543
|
+
if (input.state === "consumed") {
|
|
1544
|
+
const resumed = execution;
|
|
1545
|
+
if (resumed && ["unresolved_delivery", "waiting_peer", "waiting_owner"].includes(resumed.state) &&
|
|
1546
|
+
!this.agentRequestBlocked(resumed.id) && current) {
|
|
1547
|
+
this.writeAgentRequest({ ...resumed, state: "running", revision: resumed.revision + 1 }, resumed.revision);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
if (input.state === "unknown") {
|
|
1551
|
+
const request = execution;
|
|
1552
|
+
if (request && current && !agentRequestTerminal(request.state) && request.state !== "cancel_requested") this.writeAgentRequest({ ...request, state: "unresolved_delivery", revision: request.revision + 1 }, request.revision);
|
|
1553
|
+
}
|
|
1554
|
+
return next;
|
|
1555
|
+
}).immediate();
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
settleAgentActivation(input: { readonly activationId: string; readonly expectedRevision: number; readonly settlementId: string; readonly ownedInFlight: number; readonly reconciled: boolean }, binding: CollaborationBinding): AgentActivation {
|
|
1559
|
+
return this.db.transaction(() => {
|
|
1560
|
+
const activation = this.getAgentActivation(input.activationId);
|
|
1561
|
+
if (!activation || !isDeepStrictEqual(activation.binding, binding) || activation.revision !== input.expectedRevision ||
|
|
1562
|
+
!input.settlementId || !input.reconciled || input.ownedInFlight !== 0) throw new CollaborationStoreError("settlement_conflict");
|
|
1563
|
+
if (activation.quiesced) return activation;
|
|
1564
|
+
this.appendEvent(binding.convId, "agent.collaboration.settled", { ...input, binding });
|
|
1565
|
+
return this.writeAgentActivation({ ...activation, quiesced: true, revision: activation.revision + 1 }, activation.revision);
|
|
1566
|
+
}).immediate();
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** Conversations holding queued collaboration input in the request tree that a resolved decision may have unblocked. */
|
|
1570
|
+
agentDecisionDependents(decisionId: string): string[] {
|
|
1571
|
+
return this.db.query<{ conv: string }, [string]>(`SELECT DISTINCT a.target_conv conv FROM agent_question_links l
|
|
1572
|
+
JOIN agent_requests linked ON linked.id=l.request_id JOIN agent_requests r ON r.root_id=linked.root_id
|
|
1573
|
+
JOIN agent_activations a ON a.request_id=r.id WHERE l.decision_id=? AND a.state='queued'`).all(decisionId).map(row => row.conv);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
private agentRequestBlocked(requestId: string, exceptDecisionId = ""): boolean {
|
|
1577
|
+
return this.db.query(`WITH RECURSIVE ancestry AS (SELECT id,parent_id FROM agent_requests WHERE id=? UNION ALL SELECT p.id,p.parent_id FROM agent_requests p JOIN ancestry c ON p.id=c.parent_id)
|
|
1578
|
+
SELECT d.id FROM agent_question_links l JOIN decisions d ON d.id=l.decision_id JOIN ancestry a ON a.id=l.request_id
|
|
1579
|
+
WHERE d.id<>? AND (d.status IN ('pending','needs_review') OR (d.status='submitted' AND COALESCE(d.command_id,'') NOT LIKE 'collaboration:%') OR EXISTS (
|
|
1580
|
+
SELECT 1 FROM security_holds h WHERE h.id=d.id AND json_extract(h.data,'$.source')='collaboration_artifact'
|
|
1581
|
+
AND json_extract(h.data,'$.state') IN ('pending','allowed') AND json_extract(h.data,'$.continuation.state')<>'released')) LIMIT 1`).get(requestId, exceptDecisionId) !== null;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* Links a question observed during a collaboration execution to that request once. An owner turn (no activation) is
|
|
1586
|
+
* the owner's own context and is never linked; a replayed observation of the same decision is a no-op.
|
|
1587
|
+
*/
|
|
1588
|
+
linkCurrentAgentDecision(conv: string, decisionId: string): boolean {
|
|
1589
|
+
const activationId = this.agentBinding(conv)?.activationId;
|
|
1590
|
+
if (!activationId) return false;
|
|
1591
|
+
if (this.db.query("SELECT 1 FROM agent_question_links WHERE decision_id=?").get(decisionId)) return false;
|
|
1592
|
+
this.linkAgentDecision(decisionId, activationId);
|
|
1593
|
+
return true;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/** The native consumption of the owner's answer in its request continuation is the decision's delivery receipt. */
|
|
1597
|
+
private deliverCollaborationAnswer(activationId: string): void {
|
|
1598
|
+
const decision = this.db.query<{ id: string }, [string]>("SELECT id FROM decisions WHERE command_id=? AND status='submitted'").get(`collaboration:${activationId}`);
|
|
1599
|
+
const current = decision ? this.getDecision(decision.id) : null;
|
|
1600
|
+
if (current) this.transitionDecision(current.id, current.revision, { status: "delivered", reason: "collaboration_answer_consumed" });
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
linkAgentDecision(decisionId: string, activationId: string): void {
|
|
1604
|
+
this.db.transaction(() => {
|
|
1605
|
+
const activation = this.getAgentActivation(activationId);
|
|
1606
|
+
const decision = this.getDecision(decisionId);
|
|
1607
|
+
const request = activation ? this.agentExecutionRequest(activation) : null;
|
|
1608
|
+
// The executing input may still await its consumption evidence (recorded at settle); the exact current binding proves it runs.
|
|
1609
|
+
if (!activation || !activation.binding || !decision || decision.ownerConv !== activation.targetConv ||
|
|
1610
|
+
!["attempted", "acknowledged", "consumed"].includes(activation.state) || activation.quiesced ||
|
|
1611
|
+
!isDeepStrictEqual(this.agentBinding(activation.targetConv), activation.binding) ||
|
|
1612
|
+
this.getConversation(activation.targetConv)?.threadId !== activation.binding.sessionPath ||
|
|
1613
|
+
(activation.binding.agentId !== null && this.ensureAgentLifecycle(activation.binding.agentId) !== activation.binding.lifecycleEpoch) ||
|
|
1614
|
+
(request && (request.targetConv !== activation.targetConv || agentRequestTerminal(request.state) || request.state === "cancel_requested"))) throw new CollaborationStoreError("decision_link_conflict");
|
|
1615
|
+
this.db.query("INSERT INTO agent_question_links(decision_id,request_id,activation_id,origin_input_seq) VALUES (?,?,?,?)").run(decisionId, request?.id ?? null, activation.id, activation.inputSeq);
|
|
1616
|
+
if (request) this.writeAgentRequest({ ...request, state: "waiting_owner", revision: request.revision + 1 }, request.revision);
|
|
1617
|
+
}).immediate();
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
agentResult(id: string): AgentResult | null {
|
|
1621
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_results WHERE id=?").get(id);
|
|
1622
|
+
return row ? JSON.parse(row.data) : null;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
/** The explicit result the executing activation submitted, committed only after that activation settles. */
|
|
1626
|
+
agentResultIntent(activationId: string): AgentResultInput | null {
|
|
1627
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM events WHERE type='agent.collaboration.result_intent' AND json_extract(data,'$.activationId')=? ORDER BY seq DESC LIMIT 1").get(activationId);
|
|
1628
|
+
return row ? (JSON.parse(row.data) as { input: AgentResultInput }).input : null;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/** One explicit result per activation: the same submission returns its receipt, a different one is a conflict. */
|
|
1632
|
+
recordAgentResultIntent(input: AgentResultInput, binding: CollaborationBinding): AgentReceipt {
|
|
1633
|
+
return this.db.transaction(() => {
|
|
1634
|
+
if (binding.readContinuationId !== undefined) throw new CollaborationStoreError("read_only_continuation", 403);
|
|
1635
|
+
this.assertAgentBinding(binding);
|
|
1636
|
+
const activation = binding.activationId === null ? null : this.getAgentActivation(binding.activationId);
|
|
1637
|
+
const request = this.getAgentRequest(input.requestId);
|
|
1638
|
+
if (!activation || !request || this.agentExecutionRequest(activation)?.id !== request.id || request.targetConv !== binding.convId ||
|
|
1639
|
+
!isDeepStrictEqual(activation.binding, binding) || activation.state !== "consumed") throw new CollaborationStoreError("result_binding_conflict");
|
|
1640
|
+
const prior = this.agentResultIntent(activation.id);
|
|
1641
|
+
if (prior) {
|
|
1642
|
+
if (prior.toolCallId !== input.toolCallId || collaborationDigest({ ...prior, expectedRevision: 0 }) !== collaborationDigest({ ...input, expectedRevision: 0 })) {
|
|
1643
|
+
throw new CollaborationStoreError("result_conflict");
|
|
1644
|
+
}
|
|
1645
|
+
return { requestId: request.id, state: request.state, revision: request.revision };
|
|
1646
|
+
}
|
|
1647
|
+
if (request.state !== "result_preparing" || request.revision !== input.expectedRevision || request.resultId !== null ||
|
|
1648
|
+
activation.quiesced) throw new CollaborationStoreError("result_conflict");
|
|
1649
|
+
if (Buffer.byteLength(input.text) > 65536 || input.artifacts.length > 10) throw new CollaborationStoreError("result_too_large", 413);
|
|
1650
|
+
for (const id of input.artifacts) {
|
|
1651
|
+
const artifact = this.agentArtifact(id);
|
|
1652
|
+
if (!artifact || artifact.requestId !== request.id || artifact.provenance.producedBy?.activationId !== activation.id || artifact.state !== "staged") {
|
|
1653
|
+
throw new CollaborationStoreError("result_artifact_conflict");
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
this.appendEvent(binding.convId, "agent.collaboration.result_intent", { activationId: activation.id, input });
|
|
1657
|
+
return { requestId: request.id, state: request.state, revision: request.revision };
|
|
1658
|
+
}).immediate();
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
/**
|
|
1662
|
+
* After the executing activation settled: commit its submitted result (and the reply outbox) once, or mark a running
|
|
1663
|
+
* request that ended without a result as interrupted/result_missing. Waiting requests keep waiting; nothing reruns.
|
|
1664
|
+
*/
|
|
1665
|
+
finalizeAgentActivation(activationId: string, gateFor: (request: AgentRequest) => CollaborationGate): { readonly result: AgentResult | null; readonly missing: boolean } {
|
|
1666
|
+
return this.db.transaction(() => {
|
|
1667
|
+
const none = { result: null, missing: false };
|
|
1668
|
+
const activation = this.getAgentActivation(activationId);
|
|
1669
|
+
if (!activation?.binding || !activation.quiesced || activation.state !== "consumed") return none;
|
|
1670
|
+
const request = this.agentExecutionRequest(activation);
|
|
1671
|
+
if (!request || request.targetConv !== activation.targetConv) return none;
|
|
1672
|
+
if (request.state === "cancel_requested") {
|
|
1673
|
+
// The cancel won before any result commit: a late result stays an audited intent and wakes nobody.
|
|
1674
|
+
this.settleCancelledAgentRequest(request.id);
|
|
1675
|
+
return none;
|
|
1676
|
+
}
|
|
1677
|
+
const intent = this.agentResultIntent(activation.id);
|
|
1678
|
+
if (intent) {
|
|
1679
|
+
if (request.state !== "result_preparing" || request.resultId !== null) return none;
|
|
1680
|
+
return { result: this.commitAgentResult({ ...intent, expectedRevision: request.revision }, activation.binding, gateFor(request)), missing: false };
|
|
1681
|
+
}
|
|
1682
|
+
if ((request.state !== "running" && request.state !== "result_preparing") || this.agentRequestBlocked(request.id) ||
|
|
1683
|
+
this.db.query(`SELECT id FROM agent_requests WHERE parent_id=? AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}`).get(request.id)) return none;
|
|
1684
|
+
this.replyWithoutResult(this.writeAgentRequest({ ...request, state: "interrupted", revision: request.revision + 1, cancelReason: "result_missing" }, request.revision), "result_missing");
|
|
1685
|
+
this.appendEvent(request.targetConv, "agent.collaboration.result_missing", { requestId: request.id, activationId: activation.id });
|
|
1686
|
+
return { result: null, missing: true };
|
|
1687
|
+
}).immediate();
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
commitAgentResult(input: AgentResultInput, binding: CollaborationBinding, gate: CollaborationGate): AgentResult {
|
|
1691
|
+
return this.db.transaction(() => {
|
|
1692
|
+
if (binding.readContinuationId !== undefined) throw new CollaborationStoreError("read_only_continuation", 403);
|
|
1693
|
+
const key = JSON.stringify([binding.durableSessionId, input.toolCallId, "result"]);
|
|
1694
|
+
const prior = this.agentOperation<AgentResult>(key, { input, binding });
|
|
1695
|
+
if (prior) return prior;
|
|
1696
|
+
const request = this.getAgentRequest(input.requestId);
|
|
1697
|
+
const activation = binding.activationId === null ? null : this.getAgentActivation(binding.activationId);
|
|
1698
|
+
if (!request || !activation || this.agentExecutionRequest(activation)?.id !== request.id || request.targetConv !== binding.convId ||
|
|
1699
|
+
!isDeepStrictEqual(activation.binding, binding)) throw new CollaborationStoreError("result_binding_conflict");
|
|
1700
|
+
this.checkAgentAccess(gate, binding, this.agentTarget(request.targetAgent));
|
|
1701
|
+
if (binding.lifecycleEpoch !== request.targetLifecycleEpoch) throw new CollaborationStoreError("lifecycle_conflict");
|
|
1702
|
+
if (request.revision !== input.expectedRevision || request.resultId !== null || !["running", "result_preparing"].includes(request.state) ||
|
|
1703
|
+
activation.state !== "consumed" || !activation.quiesced || this.agentRequestBlocked(request.id) ||
|
|
1704
|
+
this.db.query(`SELECT id FROM agent_requests WHERE parent_id=? AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}`).get(request.id)) throw new CollaborationStoreError("result_conflict");
|
|
1705
|
+
if (Buffer.byteLength(input.text) > 65536 || input.artifacts.length > 10) throw new CollaborationStoreError("result_too_large", 413);
|
|
1706
|
+
const resultId = crypto.randomUUID();
|
|
1707
|
+
const manifest = input.artifacts.map(id => {
|
|
1708
|
+
const artifact = this.agentArtifact(id);
|
|
1709
|
+
if (!artifact || artifact.producerConv !== binding.convId || artifact.requestId !== request.id || artifact.provenance.producedBy?.activationId !== activation.id || artifact.state !== "staged") throw new CollaborationStoreError("result_artifact_conflict");
|
|
1710
|
+
const committed: AgentArtifact = { ...artifact, state: "committed", resultId };
|
|
1711
|
+
this.db.query("UPDATE agent_artifacts SET data=? WHERE id=?").run(JSON.stringify(committed), artifact.id);
|
|
1712
|
+
const recipient = this.getConversation(request.requesterConv);
|
|
1713
|
+
if (!recipient) throw new CollaborationStoreError("requester_not_found", 404);
|
|
1714
|
+
this.createAgentGrant(committed, { convId: request.requesterConv, lifecycleEpoch: recipient.agentId ? this.ensureAgentLifecycle(recipient.agentId) : 1,
|
|
1715
|
+
authorityEpoch: gate.mode === "authority" && recipient.agentId ? gate.access(this.db, recipient.agentId).authorityEpoch : undefined });
|
|
1716
|
+
return committed;
|
|
1717
|
+
});
|
|
1718
|
+
const result: AgentResult = { id: resultId, requestId: request.id, activationId: activation.id, submissionKey: key, payloadSha: collaborationDigest(input), text: input.text, outcome: input.outcome, manifest, committedAt: new Date().toISOString() };
|
|
1719
|
+
this.db.query("INSERT INTO agent_results(id,data) VALUES (?,?)").run(result.id, JSON.stringify(result));
|
|
1720
|
+
const completed = this.writeAgentRequest({ ...request, state: input.outcome, revision: request.revision + 1, resultId: result.id }, request.revision);
|
|
1721
|
+
const message = this.appendEvent(request.requesterConv, "message.collaboration", { requestId: request.id, rootId: request.rootId, resultId: result.id, direction: "reply", trust: "delegated", text: input.text, manifest });
|
|
1722
|
+
this.queueCollaborationEvent(message);
|
|
1723
|
+
this.queueCollaborationEvent(this.appendEvent(request.targetConv, "message.collaboration", { requestId: request.id, resultId: result.id, direction: "reply", outgoing: true, trust: "delegated" }));
|
|
1724
|
+
const parent = request.parentId === null ? null : this.getAgentRequest(request.parentId);
|
|
1725
|
+
if (!parent || (!agentRequestTerminal(parent.state) && parent.state !== "cancel_requested")) this.newAgentActivation(completed, "reply", message.seq);
|
|
1726
|
+
return this.saveAgentOperation(binding.convId, { key, payload: { input, binding }, receipt: result });
|
|
1727
|
+
}).immediate();
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
cancelAgentRequest(input: { readonly requestId: string; readonly expectedRevision: number; readonly reason: string }, binding: CollaborationBinding): AgentCancelReceipt {
|
|
1731
|
+
return this.db.transaction(() => {
|
|
1732
|
+
this.assertAgentBinding(binding);
|
|
1733
|
+
const request = this.getAgentRequest(input.requestId);
|
|
1734
|
+
if (!request) throw new CollaborationStoreError("request_not_found", 404);
|
|
1735
|
+
if (request.requesterConv !== binding.convId) throw new CollaborationStoreError("cancel_forbidden", 403);
|
|
1736
|
+
return this.cancelAgentRequestTree(request, input);
|
|
1737
|
+
}).immediate();
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/** The owner (authenticated at the caller boundary) may cancel any request tree; the same CAS rules apply. */
|
|
1741
|
+
cancelAgentRequestAsOwner(input: { readonly requestId: string; readonly expectedRevision: number; readonly reason: string }): AgentCancelReceipt {
|
|
1742
|
+
return this.db.transaction(() => {
|
|
1743
|
+
const request = this.getAgentRequest(input.requestId);
|
|
1744
|
+
if (!request) throw new CollaborationStoreError("request_not_found", 404);
|
|
1745
|
+
return this.cancelAgentRequestTree(request, input);
|
|
1746
|
+
}).immediate();
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/**
|
|
1750
|
+
* The owner pressed stop on the conversation's current run, which executes this activation's request: the same owner
|
|
1751
|
+
* cancel as the cancel route, from the request's current revision. Null when that run executes no open request.
|
|
1752
|
+
*/
|
|
1753
|
+
cancelStoppedAgentExecution(convId: string, activationId: string, reason: string): AgentCancelReceipt | null {
|
|
1754
|
+
return this.db.transaction(() => {
|
|
1755
|
+
const activation = this.getAgentActivation(activationId);
|
|
1756
|
+
if (!activation || activation.targetConv !== convId || this.agentBinding(convId)?.activationId !== activationId || activation.quiesced ||
|
|
1757
|
+
!["attempted", "acknowledged", "consumed"].includes(activation.state)) return null;
|
|
1758
|
+
const request = this.agentExecutionRequest(activation);
|
|
1759
|
+
if (!request || agentRequestTerminal(request.state) || request.state === "cancel_requested") return null;
|
|
1760
|
+
return this.cancelAgentRequestTree(request, { expectedRevision: request.revision, reason });
|
|
1761
|
+
}).immediate();
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/** A reply delivered to a root requester executes no request: once its run ends, nothing else waits to settle it. */
|
|
1765
|
+
agentReplyWithoutExecution(activationId: string): boolean {
|
|
1766
|
+
const activation = this.getAgentActivation(activationId);
|
|
1767
|
+
return activation?.direction === "reply" && this.agentExecutionRequest(activation) === null;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/** A final request (interrupted and version_conflict included) is never rewritten; the receipt reports each descendant's state. */
|
|
1771
|
+
private cancelAgentRequestTree(request: AgentRequest, input: { readonly expectedRevision: number; readonly reason: string }): AgentCancelReceipt {
|
|
1772
|
+
return this.db.transaction(() => {
|
|
1773
|
+
if (request.revision !== input.expectedRevision || agentRequestTerminal(request.state)) throw new CollaborationStoreError("request_revision_conflict");
|
|
1774
|
+
const rows = this.db.query<{ data: string }, [string]>(`WITH RECURSIVE tree AS (SELECT * FROM agent_requests WHERE id=? UNION ALL SELECT c.* FROM agent_requests c JOIN tree p ON c.parent_id=p.id) SELECT data FROM tree`).all(request.id);
|
|
1775
|
+
const descendants = rows.map(row => (JSON.parse(row.data) as AgentRequest).id)
|
|
1776
|
+
.filter(id => id !== request.id);
|
|
1777
|
+
let receipt: AgentReceipt = { requestId: request.id, state: request.state, revision: request.revision };
|
|
1778
|
+
const cancelled: AgentRequest[] = [];
|
|
1779
|
+
for (const row of rows.reverse()) {
|
|
1780
|
+
const child: AgentRequest = JSON.parse(row.data);
|
|
1781
|
+
if (agentRequestTerminal(child.state)) continue;
|
|
1782
|
+
const active = this.db.query<{ data: string }, [string, string]>(`SELECT a.data FROM agent_activations a JOIN agent_requests r ON r.id=a.request_id
|
|
1783
|
+
WHERE a.state NOT IN ('queued','rejected') AND a.quiesced=0 AND (a.request_id=? OR r.parent_id=?)`).all(child.id, child.id)
|
|
1784
|
+
.some(row => { const activation: AgentActivation = JSON.parse(row.data); return this.agentExecutionRequest(activation)?.id === child.id; }) ||
|
|
1785
|
+
this.db.query<{ data: string }, []>(`SELECT r.data FROM events r WHERE r.type='agent.collaboration.reader'
|
|
1786
|
+
AND NOT EXISTS (SELECT 1 FROM events s WHERE s.type='agent.collaboration.reader.settled' AND json_extract(s.data,'$.readId')=json_extract(r.data,'$.readId'))`).all()
|
|
1787
|
+
.some(row => {
|
|
1788
|
+
const reader: { grant: ArtifactGrant; binding: CollaborationBinding } = JSON.parse(row.data);
|
|
1789
|
+
const activation = reader.binding.activationId === null ? null : this.getAgentActivation(reader.binding.activationId);
|
|
1790
|
+
return (activation === null ? reader.grant.requestId : this.agentExecutionRequest(activation)?.id) === child.id;
|
|
1791
|
+
}) ||
|
|
1792
|
+
this.db.query(`SELECT h.id FROM security_holds h JOIN agent_question_links l ON l.decision_id=h.id WHERE l.request_id=?
|
|
1793
|
+
AND json_extract(h.data,'$.source')='collaboration_artifact' AND json_extract(h.data,'$.continuation.dispatchReceiptId') IS NOT NULL
|
|
1794
|
+
AND NOT EXISTS (SELECT 1 FROM events e WHERE e.type='agent.collaboration.read.settled' AND json_extract(e.data,'$.holdId')=h.id)`).get(child.id) !== null ||
|
|
1795
|
+
this.db.query("SELECT transfer_id FROM agent_artifact_stages WHERE origin_request_id=? AND state IN ('staging','unknown')").get(child.id) !== null;
|
|
1796
|
+
const unsettledChild = this.db.query(`SELECT id FROM agent_requests WHERE parent_id=? AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}`).get(child.id);
|
|
1797
|
+
const next = this.writeAgentRequest({ ...child, state: active || unsettledChild ? "cancel_requested" : "cancelled", revision: child.revision + 1, cancelReason: input.reason }, child.revision);
|
|
1798
|
+
this.closeQueuedAgentInputs(child.id);
|
|
1799
|
+
if (next.state === "cancelled") cancelled.push(next);
|
|
1800
|
+
if (child.id === request.id) receipt = { requestId: child.id, state: next.state, revision: next.revision };
|
|
1801
|
+
}
|
|
1802
|
+
// After the whole tree changed, so a child inside it never replies to a parent cancelled with it.
|
|
1803
|
+
for (const next of cancelled) this.replyWithoutResult(next, input.reason);
|
|
1804
|
+
return { ...receipt, children: descendants.map(id => {
|
|
1805
|
+
const child = this.getAgentRequest(id);
|
|
1806
|
+
if (!child) throw new CollaborationStoreError("request_not_found", 404);
|
|
1807
|
+
return { requestId: child.id,
|
|
1808
|
+
state: child.state, revision: child.revision };
|
|
1809
|
+
}) };
|
|
1810
|
+
}).immediate();
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
listAgentRequests(conv: string, cursor?: string): { readonly requests: readonly AgentRequest[]; readonly nextCursor: string | null } {
|
|
1814
|
+
let after: { createdAt: string; id: string } | null = null;
|
|
1815
|
+
if (cursor !== undefined) {
|
|
1816
|
+
let value: unknown;
|
|
1817
|
+
try { value = JSON.parse(Buffer.from(cursor, "base64url").toString()); }
|
|
1818
|
+
catch (error) {
|
|
1819
|
+
if (error instanceof SyntaxError) throw new CollaborationStoreError("invalid_request_cursor", 400);
|
|
1820
|
+
throw error;
|
|
1821
|
+
}
|
|
1822
|
+
if (!object(value) || typeof value.createdAt !== "string" || typeof value.id !== "string") throw new CollaborationStoreError("invalid_request_cursor", 400);
|
|
1823
|
+
after = { createdAt: value.createdAt, id: value.id };
|
|
1824
|
+
}
|
|
1825
|
+
const rows = this.db.query<{ data: string; id: string; created_at: string }, [string, string, string, string, string, string]>("SELECT data,id,created_at FROM agent_requests WHERE (requester_conv=? OR target_conv=?) AND (?='' OR created_at<? OR (created_at=? AND id<?)) ORDER BY created_at DESC,id DESC LIMIT 51").all(conv, conv, after?.createdAt ?? "", after?.createdAt ?? "", after?.createdAt ?? "", after?.id ?? "");
|
|
1826
|
+
const page = rows.slice(0, 50);
|
|
1827
|
+
const last = page.at(-1);
|
|
1828
|
+
return { requests: page.map(row => JSON.parse(row.data)), nextCursor: rows.length > 50 && last ? Buffer.from(JSON.stringify({ createdAt: last.created_at, id: last.id })).toString("base64url") : null };
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/** Writer-transaction lineage: admitted inputs, actual release claims, and consumed child replies only. */
|
|
1832
|
+
private deriveOutputLineage(requestId: string): { readonly storageClass: AgentArtifact["storageClass"]; readonly inputArtifactIds: readonly string[] } {
|
|
1833
|
+
const inputs = new Set<string>();
|
|
1834
|
+
const pending = new Set([requestId]);
|
|
1835
|
+
for (const id of pending) {
|
|
1836
|
+
const request = this.getAgentRequest(id);
|
|
1837
|
+
if (!request) throw new CollaborationStoreError("request_not_found", 404);
|
|
1838
|
+
for (const artifactId of request.payload.files) inputs.add(artifactId);
|
|
1839
|
+
const activations = this.db.query<{ data: string }, [string, string, string]>(`SELECT a.data FROM agent_activations a
|
|
1840
|
+
JOIN agent_requests r ON r.id=a.request_id WHERE a.target_conv=? AND a.state='consumed' AND (a.request_id=? OR r.parent_id=?)`)
|
|
1841
|
+
.all(request.targetConv, id, id).map(row => {
|
|
1842
|
+
const activation: AgentActivation = JSON.parse(row.data);
|
|
1843
|
+
return activation;
|
|
1844
|
+
});
|
|
1845
|
+
// Both raw and held release use the original reader operation. A fresh held binding does not change its owner.
|
|
1846
|
+
const readers = this.db.query<{ data: string }, [string]>(`SELECT r.data FROM events r JOIN events e
|
|
1847
|
+
ON json_extract(e.data,'$.readOperationId')=json_extract(r.data,'$.readId')
|
|
1848
|
+
LEFT JOIN security_holds h ON h.id=json_extract(e.data,'$.holdId')
|
|
1849
|
+
WHERE r.type='agent.collaboration.reader' AND r.conv=? AND e.type='agent.collaboration.release'
|
|
1850
|
+
AND (json_extract(e.data,'$.holdId') IS NULL OR json_extract(h.data,'$.continuation.releaseReceiptId')=CAST(e.seq AS TEXT))`)
|
|
1851
|
+
.all(request.targetConv).map(row => {
|
|
1852
|
+
const reader: { readonly grant: ArtifactGrant; readonly binding: CollaborationBinding } = JSON.parse(row.data);
|
|
1853
|
+
return reader;
|
|
1854
|
+
});
|
|
1855
|
+
for (const activation of activations) {
|
|
1856
|
+
if (this.agentExecutionRequest(activation)?.id !== id) continue;
|
|
1857
|
+
for (const reader of readers) {
|
|
1858
|
+
if (isDeepStrictEqual(reader.binding, activation.binding)) inputs.add(reader.grant.artifactId);
|
|
1859
|
+
}
|
|
1860
|
+
switch (activation.direction) {
|
|
1861
|
+
case "request": case "owner_answer": break;
|
|
1862
|
+
case "reply": {
|
|
1863
|
+
// A reply to a child that ended without a result carries no files.
|
|
1864
|
+
if (this.getAgentRequest(activation.requestId)?.resultId != null) {
|
|
1865
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_results WHERE request_id=?").get(activation.requestId);
|
|
1866
|
+
if (!row) throw new CollaborationStoreError("result_not_found", 404);
|
|
1867
|
+
const result: AgentResult = JSON.parse(row.data);
|
|
1868
|
+
for (const artifact of result.manifest) inputs.add(artifact.id);
|
|
1869
|
+
}
|
|
1870
|
+
// The child's inputs remain relevant even if its committed result has no files.
|
|
1871
|
+
pending.add(activation.requestId);
|
|
1872
|
+
break;
|
|
1873
|
+
}
|
|
1874
|
+
default: collaborationNever(activation.direction);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
const inputArtifactIds = [...inputs].sort();
|
|
1879
|
+
return { inputArtifactIds, storageClass: inputArtifactIds.some(id => this.agentArtifact(id)?.storageClass === "scoped_document") ? "scoped_document" : "source_copy" };
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
prepareAgentResult(input: { readonly requestId: string; readonly expectedRevision: number; readonly files: readonly { readonly sha256: string; readonly bytes: number; readonly mediaType: string; readonly name: string; readonly sealedName: string }[] }, binding: CollaborationBinding): AgentArtifact[] {
|
|
1883
|
+
return this.db.transaction(() => {
|
|
1884
|
+
if (binding.readContinuationId !== undefined) throw new CollaborationStoreError("read_only_continuation", 403);
|
|
1885
|
+
this.assertAgentBinding(binding);
|
|
1886
|
+
const request = this.getAgentRequest(input.requestId);
|
|
1887
|
+
const activation = binding.activationId === null ? null : this.getAgentActivation(binding.activationId);
|
|
1888
|
+
if (!request || !activation || request.targetConv !== binding.convId || this.agentExecutionRequest(activation)?.id !== request.id ||
|
|
1889
|
+
!isDeepStrictEqual(activation.binding, binding) || binding.lifecycleEpoch !== request.targetLifecycleEpoch ||
|
|
1890
|
+
activation.state !== "consumed" || request.state !== "running" || request.revision !== input.expectedRevision || this.agentRequestBlocked(request.id)) throw new CollaborationStoreError("result_conflict");
|
|
1891
|
+
if (input.files.length > 10) throw new CollaborationStoreError("result_too_large", 413);
|
|
1892
|
+
const lineage = this.deriveOutputLineage(request.id);
|
|
1893
|
+
const artifacts = input.files.map((file, ordinal): AgentArtifact => {
|
|
1894
|
+
if (!/^[a-f0-9]{64}$/.test(file.sha256) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > 10485760 || !file.sealedName) throw new CollaborationStoreError("invalid_artifact", 400);
|
|
1895
|
+
const artifact: AgentArtifact = { id: crypto.randomUUID(), producerConv: binding.convId, requestId: request.id, sha256: file.sha256, bytes: file.bytes, mediaType: file.mediaType,
|
|
1896
|
+
originalName: file.name, sealedName: file.sealedName, state: "staged", lifecycleEpoch: binding.lifecycleEpoch, storageClass: lineage.storageClass,
|
|
1897
|
+
stageOperationKey: JSON.stringify(["result", activation.id, ordinal]), stagePayloadSha: collaborationDigest(file),
|
|
1898
|
+
provenance: { transferId: null, transferAuthorityRef: null, targetAgentId: request.requesterAgent ?? "main", targetConvId: request.requesterConv,
|
|
1899
|
+
targetLifecycleEpoch: request.requesterAgent ? this.ensureAgentLifecycle(request.requesterAgent) : 1,
|
|
1900
|
+
sourceInputId: binding.inputId, sourceActivationId: activation.id, requestId: request.id, rootId: request.rootId,
|
|
1901
|
+
producedBy: { requestId: request.id, activationId: activation.id, producerConv: binding.convId }, inputArtifactIds: lineage.inputArtifactIds } };
|
|
1902
|
+
this.db.query("INSERT INTO agent_artifacts(id,data) VALUES (?,?)").run(artifact.id, JSON.stringify(artifact));
|
|
1903
|
+
return artifact;
|
|
1904
|
+
});
|
|
1905
|
+
this.writeAgentRequest({ ...request, state: "result_preparing", revision: request.revision + 1 }, request.revision);
|
|
1906
|
+
return artifacts;
|
|
1907
|
+
}).immediate();
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
agentArtifactGrant(artifactId: string, conv: string, requestId: string): ArtifactGrant | null {
|
|
1911
|
+
const row = this.db.query<{ data: string }, [string, string, string]>("SELECT data FROM agent_artifact_grants WHERE artifact_id=? AND grantee_conv=? AND request_id=? AND permission='read'").get(artifactId, conv, requestId);
|
|
1912
|
+
return row ? JSON.parse(row.data) : null;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
private assertArtifactGrant(grant: ArtifactGrant, binding: CollaborationBinding, gate: CollaborationGate): AgentArtifact {
|
|
1916
|
+
this.assertAgentBinding(binding);
|
|
1917
|
+
const artifact = this.agentArtifact(grant.artifactId);
|
|
1918
|
+
const current = this.agentGrant(grant.grantId);
|
|
1919
|
+
if (!artifact || artifact.state === "trashed" || !current || current.revoked) throw new CollaborationStoreError("artifact_revoked", 410);
|
|
1920
|
+
if (!isDeepStrictEqual(current, grant) || grant.granteeConv !== binding.convId || grant.granteeLifecycleEpoch !== binding.lifecycleEpoch || artifact.requestId !== grant.requestId) throw new CollaborationStoreError("artifact_review_stale");
|
|
1921
|
+
const request = this.getAgentRequest(grant.requestId);
|
|
1922
|
+
if (!request || request.state === "cancel_requested" || request.state === "cancelled") throw new CollaborationStoreError("read_cancelled");
|
|
1923
|
+
if (gate.mode === "authority" && binding.agentId !== null) {
|
|
1924
|
+
const access = gate.access(this.db, binding.agentId);
|
|
1925
|
+
if (access.accessState === "blocked") throw new CollaborationStoreError("agent_access_blocked", 403);
|
|
1926
|
+
if (access.authorityEpoch !== grant.recipientAuthorityEpoch || access.lifecycleEpoch !== binding.lifecycleEpoch) throw new CollaborationStoreError("authority_epoch_mismatch");
|
|
1927
|
+
}
|
|
1928
|
+
return artifact;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
private assertAgentReadExecution(binding: CollaborationBinding, hold?: ArtifactSecurityHold): AgentRequest | null {
|
|
1932
|
+
const activationId = hold ? hold.readScope.activationId : binding.activationId;
|
|
1933
|
+
if (activationId === null) return null;
|
|
1934
|
+
const activation = this.getAgentActivation(activationId);
|
|
1935
|
+
if (!activation || activation.state !== "consumed" ||
|
|
1936
|
+
(!hold?.continuation.targetBinding && !isDeepStrictEqual(activation.binding, binding))) throw new CollaborationStoreError("binding_conflict");
|
|
1937
|
+
const execution = this.agentExecutionRequest(activation);
|
|
1938
|
+
if (execution && (execution.state === "cancel_requested" || execution.state === "cancelled")) throw new CollaborationStoreError("read_cancelled");
|
|
1939
|
+
if (execution && (execution.targetConv !== binding.convId || execution.targetLifecycleEpoch !== binding.lifecycleEpoch)) throw new CollaborationStoreError("binding_conflict");
|
|
1940
|
+
if (execution && (execution.state === "result_preparing" || agentRequestTerminal(execution.state) || this.agentRequestBlocked(execution.id, hold?.decisionId ?? ""))) throw new CollaborationStoreError("request_blocked");
|
|
1941
|
+
return execution;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
beginAgentArtifactRead(input: { readonly requestId: string; readonly artifactId: string; readonly readOperationId: string }, binding: CollaborationBinding, gate: CollaborationGate): { readonly readId: string; readonly grant: ArtifactGrant } {
|
|
1945
|
+
return this.db.transaction(() => {
|
|
1946
|
+
if (binding.readContinuationId !== undefined) throw new CollaborationStoreError("read_only_continuation", 403);
|
|
1947
|
+
const key = JSON.stringify(["artifact-read", input.readOperationId]);
|
|
1948
|
+
const prior = this.agentOperation<{ readId: string; grant: ArtifactGrant }>(key, { input, binding });
|
|
1949
|
+
if (prior) throw new CollaborationStoreError("read_already_started");
|
|
1950
|
+
const grant = this.agentArtifactGrant(input.artifactId, binding.convId, input.requestId);
|
|
1951
|
+
if (!grant) throw new CollaborationStoreError("artifact_forbidden", 403);
|
|
1952
|
+
this.assertArtifactGrant(grant, binding, gate);
|
|
1953
|
+
this.assertAgentReadExecution(binding);
|
|
1954
|
+
const receipt = { readId: input.readOperationId, grant };
|
|
1955
|
+
this.appendEvent(binding.convId, "agent.collaboration.reader", { readId: input.readOperationId, grant, binding });
|
|
1956
|
+
return this.saveAgentOperation(binding.convId, { key, payload: { input, binding }, receipt });
|
|
1957
|
+
}).immediate();
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
settleAgentArtifactReader(readId: string, evidence: { readonly helperStopped: boolean; readonly leaseReleased: boolean }): void {
|
|
1961
|
+
this.db.transaction(() => {
|
|
1962
|
+
if (!evidence.helperStopped || !evidence.leaseReleased) throw new CollaborationStoreError("reader_not_settled");
|
|
1963
|
+
const row = this.db.query<{ conv: string }, [string]>("SELECT conv FROM events WHERE type='agent.collaboration.reader' AND json_extract(data,'$.readId')=?").get(readId);
|
|
1964
|
+
if (!row) throw new CollaborationStoreError("read_not_found", 404);
|
|
1965
|
+
if (this.db.query("SELECT seq FROM events WHERE type='agent.collaboration.reader.settled' AND json_extract(data,'$.readId')=?").get(readId)) return;
|
|
1966
|
+
this.appendEvent(row.conv, "agent.collaboration.reader.settled", { readId, ...evidence });
|
|
1967
|
+
}).immediate();
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
claimAgentArtifactReadRelease(readId: string, binding: CollaborationBinding, gate: CollaborationGate): { readonly readId: string; readonly grantRevision: number; readonly artifactId: string; readonly sha256: string; readonly releaseSeq: number } {
|
|
1971
|
+
return this.db.transaction(() => {
|
|
1972
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM events WHERE type='agent.collaboration.reader' AND json_extract(data,'$.readId')=?").get(readId);
|
|
1973
|
+
if (!row) throw new CollaborationStoreError("read_not_found", 404);
|
|
1974
|
+
const reader: { grant: ArtifactGrant; binding: CollaborationBinding } = JSON.parse(row.data);
|
|
1975
|
+
if (!isDeepStrictEqual(reader.binding, binding)) throw new CollaborationStoreError("binding_conflict");
|
|
1976
|
+
if (this.db.query("SELECT id FROM security_holds WHERE json_extract(data,'$.readOperationId')=?").get(readId)) throw new CollaborationStoreError("artifact_review_required", 403);
|
|
1977
|
+
if (this.db.query("SELECT seq FROM events WHERE type='agent.collaboration.release' AND json_extract(data,'$.readOperationId')=?").get(readId)) throw new CollaborationStoreError("read_already_released");
|
|
1978
|
+
const artifact = this.assertArtifactGrant(reader.grant, binding, gate);
|
|
1979
|
+
this.assertAgentReadExecution(binding);
|
|
1980
|
+
const event = this.appendEvent(binding.convId, "agent.collaboration.release", { kind: "collaboration_artifact", readOperationId: readId, artifactId: artifact.id,
|
|
1981
|
+
requestId: reader.grant.requestId, grantRevision: reader.grant.revision, sha256: artifact.sha256, releaseState: "claimed" });
|
|
1982
|
+
return { readId, grantRevision: reader.grant.revision, artifactId: artifact.id, sha256: artifact.sha256, releaseSeq: event.seq };
|
|
1983
|
+
}).immediate();
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
private agentReaders(grantId: string): { readId: string; state: "stop_requested" | "release_preceded_revoke" | "unknown" }[] {
|
|
1987
|
+
return this.db.query<{ readId: string }, [string]>(`SELECT json_extract(r.data,'$.readId') readId FROM events r WHERE r.type='agent.collaboration.reader' AND json_extract(r.data,'$.grant.grantId')=?
|
|
1988
|
+
AND NOT EXISTS (SELECT 1 FROM events s WHERE s.type='agent.collaboration.reader.settled' AND json_extract(s.data,'$.readId')=json_extract(r.data,'$.readId'))`).all(grantId).map(row => {
|
|
1989
|
+
const release = this.db.query("SELECT seq FROM events WHERE type='agent.collaboration.release' AND json_extract(data,'$.readOperationId')=?").get(row.readId);
|
|
1990
|
+
return { readId: row.readId, state: release ? "release_preceded_revoke" : "stop_requested" };
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
revokeAgentArtifactGrant(input: { readonly requestId: string; readonly artifactId: string; readonly grantId: string; readonly expectedRevision: number; readonly operationId: string; readonly ownerPrincipalRef: string; readonly granteeConv: string }) {
|
|
1995
|
+
return this.db.transaction(() => {
|
|
1996
|
+
const key = JSON.stringify(["grant-revoke", input.ownerPrincipalRef, input.operationId]);
|
|
1997
|
+
type Receipt = { operationId: string; requestId: string; artifactId: string; grantId: string; previousRevision: number; revision: number; state: "revoked"; changed: boolean; requestState: string; activeReaders: ReturnType<Store["agentReaders"]>; affectedTransfers: { transferId: string; state: string }[] };
|
|
1998
|
+
const prior = this.agentOperation<Receipt>(key, input);
|
|
1999
|
+
if (prior) return prior;
|
|
2000
|
+
const grant = this.agentGrant(input.grantId);
|
|
2001
|
+
const artifact = this.agentArtifact(input.artifactId);
|
|
2002
|
+
const request = this.getAgentRequest(input.requestId);
|
|
2003
|
+
if (!grant || !artifact || !request || grant.artifactId !== artifact.id || grant.requestId !== request.id || artifact.requestId !== request.id) throw new CollaborationStoreError("grant_not_found", 404);
|
|
2004
|
+
if (grant.granteeConv !== input.granteeConv || !input.ownerPrincipalRef) throw new CollaborationStoreError("grant_owner_mismatch", 403);
|
|
2005
|
+
if (artifact.state === "trashed") throw new CollaborationStoreError("artifact_revoked", 410);
|
|
2006
|
+
if (grant.revision !== input.expectedRevision) throw new CollaborationStoreError("grant_revision_conflict");
|
|
2007
|
+
const next = grant.revoked ? grant : { ...grant, revoked: true, revision: grant.revision + 1 };
|
|
2008
|
+
this.db.query("UPDATE agent_artifact_grants SET data=? WHERE grant_id=? AND revision=?").run(JSON.stringify(next), grant.grantId, grant.revision);
|
|
2009
|
+
const affectedTransfers: Receipt["affectedTransfers"] = [];
|
|
2010
|
+
for (const row of this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifact_stages WHERE json_extract(data,'$.source.grantId')=? AND state IN ('awaiting_transfer_authority','ready','staging','staged','unknown')").all(grant.grantId)) {
|
|
2011
|
+
const stage: AgentArtifactStage = JSON.parse(row.data);
|
|
2012
|
+
if (stage.artifactId && this.agentArtifact(stage.artifactId)?.requestId !== null) continue;
|
|
2013
|
+
const stopped: AgentArtifactStage = { ...stage, state: stage.state === "staging" || stage.state === "unknown" ? "unknown" : "invalidated", revision: stage.revision + 1, errorCode: "source_revoked" };
|
|
2014
|
+
this.db.query("UPDATE agent_artifact_stages SET data=? WHERE transfer_id=?").run(JSON.stringify(stopped), stage.transferId);
|
|
2015
|
+
affectedTransfers.push({ transferId: stage.transferId, state: stopped.state });
|
|
2016
|
+
}
|
|
2017
|
+
for (const row of this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE json_extract(data,'$.source')='collaboration_artifact' AND json_extract(data,'$.readScope.artifactGrantId')=?").all(grant.grantId)) {
|
|
2018
|
+
const hold: ArtifactSecurityHold = JSON.parse(row.data);
|
|
2019
|
+
if (hold.continuation.releaseReceiptId === null) this.writeArtifactHold({ ...hold, state: "invalidated", revision: hold.revision + 1, invalidationReason: "grant_revoked",
|
|
2020
|
+
continuation: { ...hold.continuation, state: "invalidated" } });
|
|
2021
|
+
}
|
|
2022
|
+
return this.saveAgentOperation(input.granteeConv, { key, payload: input, receipt: { operationId: input.operationId, requestId: request.id, artifactId: artifact.id, grantId: grant.grantId,
|
|
2023
|
+
previousRevision: grant.revision, revision: next.revision, state: "revoked" as const, changed: !grant.revoked, requestState: request.state, activeReaders: this.agentReaders(grant.grantId), affectedTransfers } });
|
|
2024
|
+
}).immediate();
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
artifactSecurityHold(id: string): ArtifactSecurityHold | null {
|
|
2028
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE id=? AND json_extract(data,'$.source')='collaboration_artifact'").get(id);
|
|
2029
|
+
return row ? JSON.parse(row.data) : null;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
artifactHoldByContinuation(continuationId: string): ArtifactSecurityHold | null {
|
|
2033
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE json_extract(data,'$.source')='collaboration_artifact' AND json_extract(data,'$.continuation.id')=?").get(continuationId);
|
|
2034
|
+
return row ? JSON.parse(row.data) : null;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
/** The exact binding that began this read; owner review and resume are checked against it, never a later binding. */
|
|
2038
|
+
artifactReaderBinding(readOperationId: string): CollaborationBinding {
|
|
2039
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM events WHERE type='agent.collaboration.reader' AND json_extract(data,'$.readId')=?").get(readOperationId);
|
|
2040
|
+
if (!row) throw new CollaborationStoreError("read_not_found", 404);
|
|
2041
|
+
const reader: { binding: CollaborationBinding } = JSON.parse(row.data);
|
|
2042
|
+
return reader.binding;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
unsettledArtifactHolds(activationId: string): readonly ArtifactSecurityHold[] {
|
|
2046
|
+
return this.db.query<{ data: string }, [string]>(`SELECT data FROM security_holds WHERE json_extract(data,'$.source')='collaboration_artifact'
|
|
2047
|
+
AND json_extract(data,'$.readScope.activationId')=? AND json_extract(data,'$.continuation.settledReceipt') IS NULL
|
|
2048
|
+
AND json_extract(data,'$.continuation.targetBinding') IS NULL ORDER BY rowid`).all(activationId).map(row => JSON.parse(row.data));
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
private writeArtifactHold(hold: ArtifactSecurityHold): ArtifactSecurityHold {
|
|
2052
|
+
this.db.query("UPDATE security_holds SET data=?,intent_submission=? WHERE id=?").run(JSON.stringify(hold), hold.answerSubmissionId, hold.id);
|
|
2053
|
+
this.db.query("UPDATE decisions SET revision=?,status=?,updated_at=? WHERE id=?").run(hold.revision,
|
|
2054
|
+
hold.state === "pending" ? "pending" : hold.state === "allowed" ? "delivered" : "closed", new Date().toISOString(), hold.decisionId);
|
|
2055
|
+
return hold;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
createArtifactSecurityHold(input: { readonly readOperationId: string; readonly representation: ArtifactSecurityHold["representation"]; readonly originInputSeq: number; readonly ownerPrincipalRef: string; readonly readArguments: ArtifactSecurityHold["readArguments"]; readonly summary?: ArtifactReviewSummary }, binding: CollaborationBinding, gate: CollaborationGate): ArtifactSecurityHold {
|
|
2059
|
+
return this.db.transaction(() => {
|
|
2060
|
+
const readerRow = this.db.query<{ data: string }, [string]>("SELECT data FROM events WHERE type='agent.collaboration.reader' AND json_extract(data,'$.readId')=?").get(input.readOperationId);
|
|
2061
|
+
if (!readerRow) throw new CollaborationStoreError("read_not_found", 404);
|
|
2062
|
+
const reader: { grant: ArtifactGrant; binding: CollaborationBinding } = JSON.parse(readerRow.data);
|
|
2063
|
+
if (!isDeepStrictEqual(reader.binding, binding)) throw new CollaborationStoreError("binding_conflict");
|
|
2064
|
+
const artifact = this.assertArtifactGrant(reader.grant, binding, gate);
|
|
2065
|
+
const request = artifact.requestId === null ? null : this.getAgentRequest(artifact.requestId);
|
|
2066
|
+
if (!request?.resultId || artifact.resultId !== request.resultId || this.getEvent(input.originInputSeq)?.conv !== binding.convId ||
|
|
2067
|
+
input.readArguments.requestId !== request.id || input.readArguments.artifactId !== artifact.id) throw new CollaborationStoreError("artifact_review_stale");
|
|
2068
|
+
const resourceRef = { kind: "collaboration_artifact" as const, requestId: request.id, artifactId: artifact.id, resultId: request.resultId, originalSha256: artifact.sha256 };
|
|
2069
|
+
const readScope: ArtifactSecurityHold["readScope"] = { artifactGrantId: reader.grant.grantId, grantRevision: reader.grant.revision, granteeConv: binding.convId, lifecycleEpoch: binding.lifecycleEpoch,
|
|
2070
|
+
inputId: binding.inputId, activationId: binding.activationId, managedBindingDigest: collaborationDigest(binding),
|
|
2071
|
+
...(reader.grant.recipientAuthorityEpoch === undefined ? {} : { recipientAuthorityEpoch: reader.grant.recipientAuthorityEpoch }) };
|
|
2072
|
+
const scopeDigest = collaborationDigest([resourceRef, input.representation, readScope, "collaboration_artifact_review"]);
|
|
2073
|
+
const prior = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE source_key=?").get(`artifact:${scopeDigest}`);
|
|
2074
|
+
if (prior) return JSON.parse(prior.data);
|
|
2075
|
+
const dependent = this.assertAgentReadExecution(binding);
|
|
2076
|
+
const id = crypto.randomUUID();
|
|
2077
|
+
const hold: ArtifactSecurityHold = { id, decisionId: id, revision: 1, source: "collaboration_artifact", purpose: "collaboration_artifact_review", state: "pending", resourceRef, ...(input.summary ? { summary: input.summary } : {}),
|
|
2078
|
+
representation: input.representation, readScope, scopeDigest, readOperationId: input.readOperationId, ownerPrincipalRef: input.ownerPrincipalRef,
|
|
2079
|
+
answerSubmissionId: null, reviewReceipt: null, readArguments: input.readArguments, invalidationReason: null,
|
|
2080
|
+
continuation: { id: crypto.randomUUID(), originInputSeq: input.originInputSeq, originOwnerInputRevision: binding.ownerInputRevision, settledReceipt: null, state: "unrequested",
|
|
2081
|
+
resumeSubmissionId: null, reviewedScopeDigest: null, targetBinding: null, boundScopeDigest: null, dispatchReceiptId: null, releaseReceiptId: null } };
|
|
2082
|
+
this.db.query("INSERT INTO security_holds(id,source_key,data) VALUES (?,?,?)").run(id, `artifact:${scopeDigest}`, JSON.stringify(hold));
|
|
2083
|
+
const now = new Date().toISOString();
|
|
2084
|
+
this.insertDecision({ id, revision: 1, ownerConv: binding.convId, scope: `security:${binding.convId}`, workId: null, sourceSessionPath: null, sourceRequestId: id, sourceCommandSeq: null, legacyActionId: null,
|
|
2085
|
+
method: "confirm", permission: false, required: false, payload: { kind: "security-check", purpose: "collaboration_artifact_review", holdId: id, scopeDigest }, bindingState: "closed", deadlineAt: null,
|
|
2086
|
+
status: "pending", submissionId: null, submissionRevision: null, submissionMode: null, answer: null, commandId: null, answerRoute: null, supersededBy: null, reason: null, createdAt: now, updatedAt: now });
|
|
2087
|
+
if (binding.activationId !== null) {
|
|
2088
|
+
const activation = this.getAgentActivation(binding.activationId);
|
|
2089
|
+
if (!activation) throw new CollaborationStoreError("binding_conflict");
|
|
2090
|
+
this.db.query("INSERT INTO agent_question_links(decision_id,request_id,activation_id,read_operation_id,origin_input_seq) VALUES (?,?,?,?,?)").run(id, dependent?.id ?? null, activation.id, input.readOperationId, input.originInputSeq);
|
|
2091
|
+
if (dependent && !agentRequestTerminal(dependent.state)) this.writeAgentRequest({ ...dependent, state: "waiting_owner", revision: dependent.revision + 1 }, dependent.revision);
|
|
2092
|
+
}
|
|
2093
|
+
return hold;
|
|
2094
|
+
}).immediate();
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
private assertArtifactHold(hold: ArtifactSecurityHold, binding: CollaborationBinding, gate: CollaborationGate): void {
|
|
2098
|
+
const grant = this.agentGrant(hold.readScope.artifactGrantId);
|
|
2099
|
+
if (!grant || grant.revoked) throw new CollaborationStoreError("artifact_revoked", 410);
|
|
2100
|
+
const artifact = this.assertArtifactGrant(grant, binding, gate);
|
|
2101
|
+
if (grant.revision !== hold.readScope.grantRevision || artifact.sha256 !== hold.resourceRef.originalSha256 ||
|
|
2102
|
+
binding.ownerInputRevision !== hold.continuation.originOwnerInputRevision || hold.state === "invalidated" ||
|
|
2103
|
+
hold.continuation.state === "invalidated" || hold.continuation.state === "cancelled") throw new CollaborationStoreError("artifact_review_stale");
|
|
2104
|
+
const actual: ArtifactReadBinding = { inputId: binding.inputId, activationId: binding.activationId, managedBindingDigest: collaborationDigest(binding) };
|
|
2105
|
+
const expected = hold.continuation.targetBinding ?? { inputId: hold.readScope.inputId, activationId: hold.readScope.activationId, managedBindingDigest: hold.readScope.managedBindingDigest };
|
|
2106
|
+
if (!isDeepStrictEqual(actual, expected)) throw new CollaborationStoreError("artifact_review_stale");
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
reviewArtifactHold(input: { readonly holdId: string; readonly expectedRevision: number; readonly submissionId: string; readonly scopeDigest: string; readonly choice: "allow" | "deny" | "cancel"; readonly ownerPrincipalRef: string }, binding: CollaborationBinding, gate: CollaborationGate): ArtifactReviewReceipt {
|
|
2110
|
+
return this.db.transaction(() => {
|
|
2111
|
+
const hold = this.artifactSecurityHold(input.holdId);
|
|
2112
|
+
if (!hold) throw new CollaborationStoreError("hold_not_found", 404);
|
|
2113
|
+
if (hold.ownerPrincipalRef !== input.ownerPrincipalRef) throw new CollaborationStoreError("owner_mismatch", 403);
|
|
2114
|
+
const key = JSON.stringify(["artifact-review", input.submissionId]);
|
|
2115
|
+
const prior = this.agentOperation<ArtifactReviewReceipt>(key, input);
|
|
2116
|
+
if (prior) return prior;
|
|
2117
|
+
if (hold.revision !== input.expectedRevision || hold.state !== "pending" || hold.scopeDigest !== input.scopeDigest) throw new CollaborationStoreError("artifact_review_conflict");
|
|
2118
|
+
this.assertArtifactHold(hold, binding, gate);
|
|
2119
|
+
let state: ArtifactReviewReceipt["reviewState"];
|
|
2120
|
+
switch (input.choice) {
|
|
2121
|
+
case "allow": state = "allowed"; break;
|
|
2122
|
+
case "deny": state = "denied"; break;
|
|
2123
|
+
case "cancel": state = "cancelled"; break;
|
|
2124
|
+
default: return collaborationNever(input.choice);
|
|
2125
|
+
}
|
|
2126
|
+
const revision = hold.revision + 1;
|
|
2127
|
+
this.writeArtifactHold({ ...hold, state, revision, answerSubmissionId: input.submissionId, reviewReceipt: { decisionRevision: revision, choice: input.choice } });
|
|
2128
|
+
const receipt: ArtifactReviewReceipt = { kind: "collaboration_artifact_review", holdId: hold.id, decisionId: hold.decisionId, revision, submissionId: input.submissionId,
|
|
2129
|
+
scopeDigest: hold.scopeDigest, reviewState: state, resource: { requestId: hold.resourceRef.requestId, artifactId: hold.resourceRef.artifactId, resultId: hold.resourceRef.resultId }, effect: "review_recorded", contentReleased: false };
|
|
2130
|
+
return this.saveAgentOperation(binding.convId, { key, payload: input, receipt });
|
|
2131
|
+
}).immediate();
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
settleArtifactRead(holdId: string, evidence: { readonly settlementId: string; readonly ownedInFlight: number; readonly reconciled: boolean }, binding: CollaborationBinding): ArtifactSecurityHold {
|
|
2135
|
+
return this.db.transaction(() => {
|
|
2136
|
+
const hold = this.artifactSecurityHold(holdId);
|
|
2137
|
+
if (hold && hold.continuation.targetBinding !== null) {
|
|
2138
|
+
const actual: ArtifactReadBinding = { inputId: binding.inputId, activationId: binding.activationId, managedBindingDigest: collaborationDigest(binding) };
|
|
2139
|
+
if (!evidence.settlementId || evidence.ownedInFlight !== 0 || !evidence.reconciled || binding.readContinuationId !== hold.continuation.id ||
|
|
2140
|
+
!isDeepStrictEqual(actual, hold.continuation.targetBinding)) throw new CollaborationStoreError("artifact_read_not_settled");
|
|
2141
|
+
// Settlement reconciles this exact dispatched input even after owner steer; it grants no release.
|
|
2142
|
+
if (!this.db.query("SELECT seq FROM events WHERE type='agent.collaboration.read.settled' AND json_extract(data,'$.holdId')=?").get(holdId)) {
|
|
2143
|
+
this.appendEvent(binding.convId, "agent.collaboration.read.settled", { holdId, ...evidence, binding: actual });
|
|
2144
|
+
}
|
|
2145
|
+
return hold;
|
|
2146
|
+
}
|
|
2147
|
+
if (!hold || !evidence.settlementId || evidence.ownedInFlight !== 0 || !evidence.reconciled ||
|
|
2148
|
+
hold.readScope.inputId !== binding.inputId || hold.readScope.activationId !== binding.activationId || hold.readScope.managedBindingDigest !== collaborationDigest(binding)) throw new CollaborationStoreError("artifact_read_not_settled");
|
|
2149
|
+
this.assertAgentBinding(binding);
|
|
2150
|
+
if (hold.continuation.settledReceipt !== null) return hold;
|
|
2151
|
+
return this.writeArtifactHold({ ...hold, revision: hold.revision + 1, continuation: { ...hold.continuation, settledReceipt: { inputId: binding.inputId, activationId: binding.activationId, managedBindingDigest: collaborationDigest(binding), settlementId: evidence.settlementId } } });
|
|
2152
|
+
}).immediate();
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
resumeArtifactRead(input: { readonly holdId: string; readonly expectedRevision: number; readonly resumeSubmissionId: string; readonly readOperationId: string; readonly scopeDigest: string; readonly ownerPrincipalRef: string }, binding: CollaborationBinding, gate: CollaborationGate) {
|
|
2156
|
+
return this.db.transaction(() => {
|
|
2157
|
+
const hold = this.artifactSecurityHold(input.holdId);
|
|
2158
|
+
if (!hold) throw new CollaborationStoreError("hold_not_found", 404);
|
|
2159
|
+
if (hold.ownerPrincipalRef !== input.ownerPrincipalRef || binding.convId !== hold.readScope.granteeConv) throw new CollaborationStoreError("owner_mismatch", 403);
|
|
2160
|
+
type Receipt = { holdId: string; readOperationId: string; continuationId: string; revision: number; resumeSubmissionId: string; scopeDigest: string; resumeState: ArtifactSecurityHold["continuation"]["state"]; contentReleased: false };
|
|
2161
|
+
const key = JSON.stringify(["artifact-resume", input.resumeSubmissionId]);
|
|
2162
|
+
const prior = this.agentOperation<Receipt>(key, input);
|
|
2163
|
+
if (prior) return { ...prior, resumeState: hold.continuation.state };
|
|
2164
|
+
if (hold.revision !== input.expectedRevision || hold.scopeDigest !== input.scopeDigest || hold.readOperationId !== input.readOperationId || hold.continuation.resumeSubmissionId !== null) throw new CollaborationStoreError("artifact_resume_conflict");
|
|
2165
|
+
switch (hold.state) {
|
|
2166
|
+
case "pending": throw new CollaborationStoreError("document_review_pending");
|
|
2167
|
+
case "denied": throw new CollaborationStoreError("document_review_denied", 403);
|
|
2168
|
+
case "cancelled": throw new CollaborationStoreError("document_review_cancelled");
|
|
2169
|
+
case "invalidated": throw new CollaborationStoreError("artifact_review_stale");
|
|
2170
|
+
case "allowed": break;
|
|
2171
|
+
default: return collaborationNever(hold.state);
|
|
2172
|
+
}
|
|
2173
|
+
this.assertArtifactHold(hold, binding, gate);
|
|
2174
|
+
if (!hold.continuation.settledReceipt) throw new CollaborationStoreError("artifact_read_not_settled");
|
|
2175
|
+
this.assertAgentReadExecution(binding, hold);
|
|
2176
|
+
const released = hold.continuation.releaseReceiptId !== null;
|
|
2177
|
+
if (!released && hold.continuation.state !== "unrequested") throw new CollaborationStoreError("artifact_resume_conflict");
|
|
2178
|
+
const next = this.writeArtifactHold({ ...hold, revision: hold.revision + 1, continuation: { ...hold.continuation,
|
|
2179
|
+
state: released ? hold.continuation.state : "queued", resumeSubmissionId: input.resumeSubmissionId, reviewedScopeDigest: hold.scopeDigest } });
|
|
2180
|
+
if (!released) {
|
|
2181
|
+
const intent: CollaborationIntent = { origin: "collaboration_artifact_read", holdId: hold.id, readOperationId: hold.readOperationId, continuationId: hold.continuation.id, reviewedScopeDigest: hold.scopeDigest };
|
|
2182
|
+
this.appendEvent(binding.convId, "agent.collaboration.input", intent);
|
|
2183
|
+
}
|
|
2184
|
+
const receipt: Receipt = { holdId: hold.id, readOperationId: hold.readOperationId, continuationId: hold.continuation.id, revision: next.revision, resumeSubmissionId: input.resumeSubmissionId, scopeDigest: hold.scopeDigest, resumeState: next.continuation.state, contentReleased: false };
|
|
2185
|
+
return this.saveAgentOperation(binding.convId, { key, payload: input, receipt });
|
|
2186
|
+
}).immediate();
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
nextArtifactReadInput(conv: string): { readonly hold: ArtifactSecurityHold; readonly seq: number } | null {
|
|
2190
|
+
const row = this.db.query<{ data: string; seq: number }, [string]>(`
|
|
2191
|
+
SELECT h.data,e.seq FROM events e JOIN security_holds h ON h.id=json_extract(e.data,'$.holdId')
|
|
2192
|
+
WHERE e.conv=? AND e.type='agent.collaboration.input' AND json_extract(e.data,'$.origin')='collaboration_artifact_read'
|
|
2193
|
+
AND json_extract(h.data,'$.state')='allowed' AND json_extract(h.data,'$.continuation.state')='queued'
|
|
2194
|
+
ORDER BY e.seq LIMIT 1`).get(conv);
|
|
2195
|
+
return row ? { hold: JSON.parse(row.data), seq: row.seq } : null;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
admitArtifactRead(input: { readonly holdId: string; readonly expectedRevision: number; readonly previousBinding: CollaborationBinding;
|
|
2199
|
+
readonly wireMessage: string }, fresh: CollaborationBinding, gate: CollaborationGate): StoredEvent {
|
|
2200
|
+
return this.db.transaction(() => {
|
|
2201
|
+
const next = this.nextArtifactReadInput(fresh.convId);
|
|
2202
|
+
if (!next || next.hold.id !== input.holdId || next.hold.revision !== input.expectedRevision ||
|
|
2203
|
+
!isDeepStrictEqual(this.agentBinding(fresh.convId), input.previousBinding)) throw new CollaborationStoreError("artifact_resume_conflict");
|
|
2204
|
+
if (this.lastSeq(fresh.convId, "message.user") !== fresh.ownerInputRevision ||
|
|
2205
|
+
this.pendingMainUsers(fresh.convId).length || this.mainTurn(fresh.convId)) throw new CollaborationStoreError("conversation_busy");
|
|
2206
|
+
const hold = this.dispatchArtifactRead({ holdId: input.holdId, fresh }, input.previousBinding, gate);
|
|
2207
|
+
const source: MainInputSource = { origin: "collaboration_artifact_read", input_ref: { holdId: hold.id, readOperationId: hold.readOperationId,
|
|
2208
|
+
continuationId: hold.continuation.id, reviewedScopeDigest: hold.scopeDigest } };
|
|
2209
|
+
return this.appendEvent(fresh.convId, "main.input", { ...source, session_path: fresh.sessionPath, handoff: null,
|
|
2210
|
+
input_id: fresh.inputId, wire_message: input.wireMessage, native_identity: true, authored_binding: fresh });
|
|
2211
|
+
}).immediate();
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
dispatchArtifactRead(input: string | { readonly holdId: string; readonly fresh: CollaborationBinding }, binding: CollaborationBinding, gate: CollaborationGate): ArtifactSecurityHold {
|
|
2215
|
+
return this.db.transaction(() => {
|
|
2216
|
+
const holdId = typeof input === "string" ? input : input.holdId;
|
|
2217
|
+
const hold = this.artifactSecurityHold(holdId);
|
|
2218
|
+
if (!hold || hold.state !== "allowed" || hold.continuation.state !== "queued" || !hold.continuation.settledReceipt || hold.continuation.targetBinding !== null) throw new CollaborationStoreError("artifact_resume_conflict");
|
|
2219
|
+
this.assertArtifactHold(hold, binding, gate);
|
|
2220
|
+
this.assertAgentReadExecution(binding, hold);
|
|
2221
|
+
if (binding.activationId !== null) {
|
|
2222
|
+
const active = this.getAgentActivation(binding.activationId);
|
|
2223
|
+
if (!active?.quiesced) throw new CollaborationStoreError("request_blocked");
|
|
2224
|
+
}
|
|
2225
|
+
const fresh: CollaborationBinding = { ...binding, inputId: typeof input === "string" ? crypto.randomUUID() : input.fresh.inputId,
|
|
2226
|
+
activationId: null, readContinuationId: hold.continuation.id };
|
|
2227
|
+
if (typeof input !== "string" && (!isDeepStrictEqual(fresh, input.fresh) || fresh.inputId === binding.inputId)) throw new CollaborationStoreError("artifact_review_stale");
|
|
2228
|
+
const targetBinding: ArtifactReadBinding = { inputId: fresh.inputId, activationId: fresh.activationId, managedBindingDigest: collaborationDigest(fresh) };
|
|
2229
|
+
const dispatch = this.appendEvent(binding.convId, "agent.collaboration.read.dispatch", { holdId, readOperationId: hold.readOperationId, continuationId: hold.continuation.id, targetBinding });
|
|
2230
|
+
this.recordAgentBinding(fresh);
|
|
2231
|
+
return this.writeArtifactHold({ ...hold, revision: hold.revision + 1, continuation: { ...hold.continuation, state: "dispatched", targetBinding,
|
|
2232
|
+
boundScopeDigest: collaborationDigest([hold.scopeDigest, hold.continuation.id, targetBinding]), dispatchReceiptId: String(dispatch.seq) } });
|
|
2233
|
+
}).immediate();
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
claimArtifactRelease(input: { readonly holdId: string; readonly expectedRevision: number; readonly representation: ArtifactSecurityHold["representation"] }, binding: CollaborationBinding, gate: CollaborationGate): ArtifactReleaseReceipt {
|
|
2237
|
+
return this.db.transaction(() => {
|
|
2238
|
+
const hold = this.artifactSecurityHold(input.holdId);
|
|
2239
|
+
if (!hold || hold.revision !== input.expectedRevision || hold.state !== "allowed" || hold.continuation.releaseReceiptId !== null ||
|
|
2240
|
+
!isDeepStrictEqual(input.representation, hold.representation) || !["unrequested", "dispatched"].includes(hold.continuation.state)) throw new CollaborationStoreError("artifact_review_stale");
|
|
2241
|
+
if (this.db.query("SELECT seq FROM events WHERE type='agent.collaboration.read.settled' AND json_extract(data,'$.holdId')=?").get(hold.id)) throw new CollaborationStoreError("artifact_read_not_settled");
|
|
2242
|
+
this.assertArtifactHold(hold, binding, gate);
|
|
2243
|
+
this.assertAgentReadExecution(binding, hold);
|
|
2244
|
+
if (hold.continuation.targetBinding === null && hold.continuation.settledReceipt !== null) throw new CollaborationStoreError("artifact_resume_required");
|
|
2245
|
+
const receipt: ArtifactReleaseReceipt = { kind: "collaboration_artifact", readOperationId: hold.readOperationId, holdId: hold.id, requestId: hold.resourceRef.requestId, artifactId: hold.resourceRef.artifactId,
|
|
2246
|
+
grantRevision: hold.readScope.grantRevision, representationSha256: hold.representation.sha256, scopeDigest: hold.scopeDigest,
|
|
2247
|
+
continuationId: hold.continuation.targetBinding === null ? null : hold.continuation.id, boundScopeDigest: hold.continuation.boundScopeDigest, releaseState: "claimed" };
|
|
2248
|
+
const event = this.appendEvent(binding.convId, "agent.collaboration.release", receipt);
|
|
2249
|
+
this.writeArtifactHold({ ...hold, revision: hold.revision + 1, continuation: { ...hold.continuation, releaseReceiptId: String(event.seq) } });
|
|
2250
|
+
return receipt;
|
|
2251
|
+
}).immediate();
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
recordArtifactDelivery(input: { readonly holdId: string; readonly dispatchReceiptId: string | null; readonly releaseReceiptId: string | null; readonly state: "released" | "unknown" }, binding: ArtifactReadBinding): ArtifactSecurityHold {
|
|
2255
|
+
return this.db.transaction(() => {
|
|
2256
|
+
const hold = this.artifactSecurityHold(input.holdId);
|
|
2257
|
+
if (!hold || hold.continuation.dispatchReceiptId !== input.dispatchReceiptId || hold.continuation.releaseReceiptId !== input.releaseReceiptId ||
|
|
2258
|
+
!isDeepStrictEqual(hold.continuation.targetBinding ?? { inputId: hold.readScope.inputId, activationId: hold.readScope.activationId, managedBindingDigest: hold.readScope.managedBindingDigest }, binding)) throw new CollaborationStoreError("artifact_delivery_conflict");
|
|
2259
|
+
if (hold.continuation.state === "released" || hold.continuation.state === "invalidated" || hold.continuation.state === "cancelled") return hold;
|
|
2260
|
+
if (input.state === "released" && input.releaseReceiptId === null) throw new CollaborationStoreError("artifact_delivery_conflict");
|
|
2261
|
+
if (input.dispatchReceiptId === null && input.releaseReceiptId === null) throw new CollaborationStoreError("artifact_delivery_conflict");
|
|
2262
|
+
this.appendEvent(hold.readScope.granteeConv, "agent.collaboration.release.evidence", { ...input, binding });
|
|
2263
|
+
return this.writeArtifactHold({ ...hold, revision: hold.revision + 1, continuation: { ...hold.continuation, state: input.state } });
|
|
2264
|
+
}).immediate();
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
markAgentStageUnknown(transferId: string, expectedRevision: number): AgentArtifactStage {
|
|
2268
|
+
return this.db.transaction(() => {
|
|
2269
|
+
const stage = this.agentStage(transferId);
|
|
2270
|
+
if (!stage || stage.state !== "staging" || stage.revision !== expectedRevision) throw new CollaborationStoreError("stage_conflict");
|
|
2271
|
+
const next: AgentArtifactStage = { ...stage, state: "unknown", revision: stage.revision + 1, errorCode: "export_unknown" };
|
|
2272
|
+
this.db.query("UPDATE agent_artifact_stages SET data=? WHERE transfer_id=? AND revision=?").run(JSON.stringify(next), transferId, expectedRevision);
|
|
2273
|
+
return next;
|
|
2274
|
+
}).immediate();
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
/**
|
|
2278
|
+
* Housekeeping candidates: sealed collaboration files of finished requests that no live grant keeps. Listing changes
|
|
2279
|
+
* nothing; trashAgentArtifact repeats every reference check at the operator's selection.
|
|
2280
|
+
*/
|
|
2281
|
+
agentArtifactCleanupRows(): readonly { readonly artifactId: string; readonly sealedName: string; readonly bytes: number; readonly state: string }[] {
|
|
2282
|
+
return this.db.query<{ data: string }, []>(`SELECT a.data FROM agent_artifacts a JOIN agent_requests r ON r.id=a.request_id
|
|
2283
|
+
WHERE a.state<>'trashed' AND r.state IN ${AGENT_REQUEST_FINAL_SQL}
|
|
2284
|
+
AND NOT EXISTS (SELECT 1 FROM agent_artifact_grants g WHERE g.artifact_id=a.id AND g.revoked=0) ORDER BY a.id`).all()
|
|
2285
|
+
.map(row => JSON.parse(row.data) as AgentArtifact)
|
|
2286
|
+
.map(artifact => ({ artifactId: artifact.id, sealedName: artifact.sealedName, bytes: artifact.bytes, state: artifact.state }));
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
trashAgentArtifact(artifactId: string): AgentArtifact {
|
|
2290
|
+
return this.db.transaction(() => {
|
|
2291
|
+
const artifact = this.agentArtifact(artifactId);
|
|
2292
|
+
if (!artifact) throw new CollaborationStoreError("artifact_not_found", 404);
|
|
2293
|
+
if (artifact.state === "trashed") return artifact;
|
|
2294
|
+
const request = artifact.requestId === null ? null : this.getAgentRequest(artifact.requestId);
|
|
2295
|
+
const ended = request !== null && agentRequestTerminal(request.state);
|
|
2296
|
+
if (request && (!ended || this.agentActivations(request.id).some(a => a.state === "unknown" ||
|
|
2297
|
+
(a.state !== "queued" && a.state !== "rejected" && !a.quiesced)))) throw new CollaborationStoreError("artifact_busy");
|
|
2298
|
+
for (const row of this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifact_grants WHERE artifact_id=?").all(artifactId)) {
|
|
2299
|
+
const grant: ArtifactGrant = JSON.parse(row.data);
|
|
2300
|
+
if (!grant.revoked || this.agentReaders(grant.grantId).length > 0) throw new CollaborationStoreError("artifact_busy");
|
|
2301
|
+
}
|
|
2302
|
+
if (this.db.query("SELECT transfer_id FROM agent_artifact_stages WHERE (artifact_id=? OR json_extract(data,'$.source.artifactId')=?) AND state IN ('awaiting_transfer_authority','ready','staging','unknown')").get(artifactId, artifactId)) throw new CollaborationStoreError("artifact_busy");
|
|
2303
|
+
if (this.db.query("SELECT id FROM security_holds WHERE json_extract(data,'$.resourceRef.artifactId')=? AND json_extract(data,'$.continuation.state') IN ('queued','dispatched','unknown')").get(artifactId)) throw new CollaborationStoreError("artifact_busy");
|
|
2304
|
+
const tombstone: AgentArtifact = { ...artifact, state: "trashed" };
|
|
2305
|
+
this.db.query("UPDATE agent_artifacts SET data=? WHERE id=?").run(JSON.stringify(tombstone), artifactId);
|
|
2306
|
+
return tombstone;
|
|
2307
|
+
}).immediate();
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
acceptCollaborationPermissionOutcome(outcome: CollaborationPermissionOutcome, binding: CollaborationBinding, gate: CollaborationGate) {
|
|
2311
|
+
return this.db.transaction(() => {
|
|
2312
|
+
const key = JSON.stringify(["permission", outcome.decisionId, outcome.decisionRevision, outcome.continuationId]);
|
|
2313
|
+
type Receipt = { readonly decisionId: string; readonly continuationId: string; readonly inputSeq: number };
|
|
2314
|
+
const prior = this.agentOperation<Receipt>(key, outcome);
|
|
2315
|
+
if (prior) return prior;
|
|
2316
|
+
this.assertCollaborationPermission(outcome, binding, gate);
|
|
2317
|
+
const intent: CollaborationIntent = { origin: "agent_tool_permission", outcome };
|
|
2318
|
+
const input = this.appendEvent(binding.convId, "agent.collaboration.input", intent);
|
|
2319
|
+
return this.saveAgentOperation(binding.convId, { key, payload: outcome, receipt: { decisionId: outcome.decisionId, continuationId: outcome.continuationId, inputSeq: input.seq } });
|
|
2320
|
+
}).immediate();
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
/** Only the shared dispatcher allocates the continuation, once per exact stored outcome. */
|
|
2324
|
+
dispatchCollaborationPermission(outcome: CollaborationPermissionOutcome, binding: CollaborationBinding, gate: CollaborationGate): AgentActivation {
|
|
2325
|
+
return this.db.transaction(() => {
|
|
2326
|
+
const key = JSON.stringify(["permission-dispatch", outcome.decisionId, outcome.decisionRevision, outcome.continuationId]);
|
|
2327
|
+
const prior = this.agentOperation<{ readonly activationId: string }>(key, outcome);
|
|
2328
|
+
if (prior) {
|
|
2329
|
+
const activation = this.getAgentActivation(prior.activationId);
|
|
2330
|
+
if (!activation) throw new CollaborationStoreError("activation_not_found");
|
|
2331
|
+
return activation;
|
|
2332
|
+
}
|
|
2333
|
+
this.assertCollaborationPermission(outcome, binding, gate);
|
|
2334
|
+
const blocked = this.getAgentActivation(outcome.blockedActivationId), request = this.getAgentRequest(outcome.collaborationRequestId);
|
|
2335
|
+
if (!blocked?.quiesced || !request) throw new CollaborationStoreError("request_blocked");
|
|
2336
|
+
const accepted = this.acceptCollaborationPermissionOutcome(outcome, binding, gate);
|
|
2337
|
+
const activation = this.newAgentActivation(request, "owner_answer", { seq: accepted.inputSeq });
|
|
2338
|
+
this.saveAgentOperation(binding.convId, { key, payload: outcome, receipt: { activationId: activation.id } });
|
|
2339
|
+
return activation;
|
|
2340
|
+
}).immediate();
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
private assertCollaborationPermission(outcome: CollaborationPermissionOutcome, binding: CollaborationBinding, gate: CollaborationGate): void {
|
|
2344
|
+
if (gate.mode !== "authority" || binding.agentId === null) throw new CollaborationStoreError("authority_adapter_required", 503);
|
|
2345
|
+
if (!gate.permissionOutcome || !isDeepStrictEqual(gate.permissionOutcome(this.db, outcome.decisionId), outcome)) throw new CollaborationStoreError("permission_outcome_conflict");
|
|
2346
|
+
this.assertAgentBinding(binding);
|
|
2347
|
+
const decision = this.getDecision(outcome.decisionId);
|
|
2348
|
+
const activation = this.getAgentActivation(outcome.blockedActivationId);
|
|
2349
|
+
const request = this.getAgentRequest(outcome.collaborationRequestId);
|
|
2350
|
+
const link = this.db.query<{ request_id: string; activation_id: string; origin_input_seq: number }, [string]>("SELECT request_id,activation_id,origin_input_seq FROM agent_question_links WHERE decision_id=?").get(outcome.decisionId);
|
|
2351
|
+
const access = gate.access(this.db, binding.agentId);
|
|
2352
|
+
// Q-07 (294 card values): the decision is a 294 grant card (permission:false, purpose agent_tool_grant); the link names
|
|
2353
|
+
// the asking execution's input; the outcome's origin input is that same execution input.
|
|
2354
|
+
const purpose = this.db.query<{ purpose: string }, [string]>("SELECT purpose FROM decision_purposes WHERE decision_id=?").get(outcome.decisionId)?.purpose;
|
|
2355
|
+
if (!decision || decision.revision !== outcome.decisionRevision || decision.submissionId === null ||
|
|
2356
|
+
!["delivered", "closed"].includes(decision.status) || decision.permission || purpose !== "agent_tool_grant" ||
|
|
2357
|
+
!activation || !request || !link || this.agentExecutionRequest(activation)?.id !== request.id || link.request_id !== request.id || link.activation_id !== activation.id ||
|
|
2358
|
+
link.origin_input_seq !== activation.inputSeq || activation.inputSeq !== outcome.originInputSeq ||
|
|
2359
|
+
!isDeepStrictEqual(activation.binding, binding) || activation.state !== "consumed" ||
|
|
2360
|
+
binding.lifecycleEpoch !== outcome.lifecycleEpoch || access.lifecycleEpoch !== outcome.lifecycleEpoch ||
|
|
2361
|
+
access.authorityEpoch !== outcome.authorityEpoch || access.policyRevision !== outcome.policyRevision) throw new CollaborationStoreError("permission_outcome_conflict");
|
|
2362
|
+
if (access.accessState !== "open") throw new CollaborationStoreError("agent_access_blocked", 403);
|
|
2363
|
+
if (agentRequestTerminal(request.state) || request.state === "cancel_requested" || this.agentRequestBlocked(request.id)) throw new CollaborationStoreError("request_blocked");
|
|
2364
|
+
}
|
|
2365
|
+
|
|
699
2366
|
securityAllowedValues(): { revision: number; values: AllowedValue[] } {
|
|
700
2367
|
return this.db.transaction(() => ({
|
|
701
2368
|
revision: this.db.query<{ revision: number }, []>("SELECT revision FROM security_allow_revision WHERE id = 1").get()!.revision,
|
|
@@ -765,16 +2432,16 @@ export class Store {
|
|
|
765
2432
|
}
|
|
766
2433
|
|
|
767
2434
|
securityHolds(): SecurityHoldRecord[] {
|
|
768
|
-
return this.db.query<{ data: string }, []>("SELECT data FROM security_holds ORDER BY rowid").all().map((row) => JSON.parse(row.data));
|
|
2435
|
+
return this.db.query<{ data: string }, []>("SELECT data FROM security_holds WHERE json_extract(data, '$.state.source') IN ('message','answer') ORDER BY rowid").all().map((row) => JSON.parse(row.data));
|
|
769
2436
|
}
|
|
770
2437
|
|
|
771
2438
|
securityHold(id: string): SecurityHoldRecord | null {
|
|
772
|
-
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE id = ?").get(id);
|
|
2439
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE id = ? AND json_extract(data, '$.state.source') IN ('message','answer')").get(id);
|
|
773
2440
|
return row ? JSON.parse(row.data) : null;
|
|
774
2441
|
}
|
|
775
2442
|
|
|
776
2443
|
securityHoldByKey(key: string): SecurityHoldRecord | null {
|
|
777
|
-
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE source_key = ?").get(key);
|
|
2444
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE source_key = ? AND json_extract(data, '$.state.source') IN ('message','answer')").get(key);
|
|
778
2445
|
return row ? JSON.parse(row.data) : null;
|
|
779
2446
|
}
|
|
780
2447
|
|
|
@@ -871,6 +2538,7 @@ export class Store {
|
|
|
871
2538
|
}
|
|
872
2539
|
|
|
873
2540
|
securityReceipt(ref: SecurityReceipt): "sent" | "unconfirmed" | null {
|
|
2541
|
+
if (ref.kind === "collaboration_artifact" || ref.kind === "collaboration_artifact_review") return null;
|
|
874
2542
|
if (ref.kind === "answer") {
|
|
875
2543
|
const decision = this.getDecision(ref.decisionId);
|
|
876
2544
|
if (decision?.submissionId !== ref.submissionId) return null;
|
|
@@ -1127,14 +2795,25 @@ export class Store {
|
|
|
1127
2795
|
const sources = (inputs.length ? inputs : events.filter((event) => event.type === "turn.started"))
|
|
1128
2796
|
.map((event) => event.data as Partial<MainInputSource>);
|
|
1129
2797
|
const explicit = [...sources].reverse().find((source) => source.origin === "user")
|
|
1130
|
-
?? sources.find((source) => source.origin
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
: {
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
2798
|
+
?? sources.find((source) => source.origin !== undefined);
|
|
2799
|
+
let source: MainInputSource;
|
|
2800
|
+
switch (explicit?.origin) {
|
|
2801
|
+
case "user": source = { origin: explicit.origin, input_ref: explicit.input_ref ?? { message_seq: this.lastSeq(conv, "message.user") } }; break;
|
|
2802
|
+
case "work_update":
|
|
2803
|
+
if (!explicit.input_ref) throw new CollaborationStoreError("input_source_missing");
|
|
2804
|
+
source = { origin: explicit.origin, input_ref: explicit.input_ref }; break;
|
|
2805
|
+
case "agent_collaboration":
|
|
2806
|
+
if (!explicit.input_ref) throw new CollaborationStoreError("input_source_missing");
|
|
2807
|
+
source = { origin: explicit.origin, input_ref: explicit.input_ref }; break;
|
|
2808
|
+
case "agent_tool_permission":
|
|
2809
|
+
if (!explicit.input_ref) throw new CollaborationStoreError("input_source_missing");
|
|
2810
|
+
source = { origin: explicit.origin, input_ref: explicit.input_ref }; break;
|
|
2811
|
+
case "collaboration_artifact_read":
|
|
2812
|
+
if (!explicit.input_ref) throw new CollaborationStoreError("input_source_missing");
|
|
2813
|
+
source = { origin: explicit.origin, input_ref: explicit.input_ref }; break;
|
|
2814
|
+
case undefined: source = { origin: "user", input_ref: { message_seq: this.lastSeq(conv, "message.user") } }; break;
|
|
2815
|
+
default: return collaborationNever(explicit);
|
|
2816
|
+
}
|
|
1138
2817
|
const report = events.filter((event) => event.type === "message.final"
|
|
1139
2818
|
&& Array.isArray((event.data as { work_update_ids?: unknown }).work_update_ids)).at(-1);
|
|
1140
2819
|
return { seq: first.seq, source, inputs, reportEventSeq: report?.seq ?? null };
|
|
@@ -1146,11 +2825,48 @@ export class Store {
|
|
|
1146
2825
|
}
|
|
1147
2826
|
|
|
1148
2827
|
/** Exact native message/history proves consumption; a queue ACK alone does not. */
|
|
1149
|
-
consumeMainInput(input: StoredEvent): StoredEvent | null {
|
|
2828
|
+
consumeMainInput(input: StoredEvent, native?: { readonly entry: LedgerEntry; readonly durableId: string }): StoredEvent | null {
|
|
1150
2829
|
return this.db.transaction(() => {
|
|
2830
|
+
const source: MainInput = JSON.parse(JSON.stringify(input.data));
|
|
2831
|
+
switch (source.origin) {
|
|
2832
|
+
case "user": case "work_update": break;
|
|
2833
|
+
case "agent_collaboration": case "agent_tool_permission": {
|
|
2834
|
+
const activation = this.getAgentActivation(source.input_ref.activation_id);
|
|
2835
|
+
const ledger = this.getLedger(input.conv);
|
|
2836
|
+
const stored = source.input_id ? this.ledgerUser(source.session_path, { clientInputId: source.input_id }) : null;
|
|
2837
|
+
if (!activation?.binding || !native || native.entry.sessionPath !== source.session_path ||
|
|
2838
|
+
native.durableId !== activation.binding.durableSessionId || native.entry.role !== "user" ||
|
|
2839
|
+
ledger?.durableId !== native.durableId || ledger.sessionPath !== source.session_path ||
|
|
2840
|
+
(source.native_identity && stored?.entryId !== native.entry.entryId) ||
|
|
2841
|
+
(source.native_identity ? native.entry.clientInputId !== source.input_id : native.entry.text !== source.wire_message)) {
|
|
2842
|
+
throw new CollaborationStoreError("consumption_evidence_required");
|
|
2843
|
+
}
|
|
2844
|
+
this.recordAgentActivationEvidence({ activationId: activation.id, expectedRevision: activation.revision,
|
|
2845
|
+
state: "consumed", consumedEntry: native.entry.entryId }, activation.binding);
|
|
2846
|
+
if (activation.direction === "owner_answer") this.deliverCollaborationAnswer(activation.id);
|
|
2847
|
+
break;
|
|
2848
|
+
}
|
|
2849
|
+
case "collaboration_artifact_read": {
|
|
2850
|
+
const hold = this.artifactSecurityHold(source.input_ref.holdId), binding = source.authored_binding;
|
|
2851
|
+
const ledger = this.getLedger(input.conv);
|
|
2852
|
+
if (!native || !binding || !hold || hold.readOperationId !== source.input_ref.readOperationId ||
|
|
2853
|
+
hold.continuation.id !== source.input_ref.continuationId || hold.scopeDigest !== source.input_ref.reviewedScopeDigest ||
|
|
2854
|
+
hold.continuation.targetBinding?.managedBindingDigest !== collaborationDigest(binding) ||
|
|
2855
|
+
hold.continuation.targetBinding.inputId !== source.input_id || native.durableId !== binding.durableSessionId ||
|
|
2856
|
+
ledger?.durableId !== native.durableId || native.entry.sessionPath !== source.session_path ||
|
|
2857
|
+
native.entry.clientInputId !== source.input_id || native.entry.role !== "user" ||
|
|
2858
|
+
this.ledgerUser(source.session_path, { clientInputId: binding.inputId })?.entryId !== native.entry.entryId) {
|
|
2859
|
+
throw new CollaborationStoreError("consumption_evidence_required");
|
|
2860
|
+
}
|
|
2861
|
+
break;
|
|
2862
|
+
}
|
|
2863
|
+
default: return collaborationNever(source);
|
|
2864
|
+
}
|
|
1151
2865
|
this.ackMainInput(input);
|
|
1152
2866
|
if (this.mainInputConsumed(input)) return null;
|
|
1153
|
-
return this.appendEvent(input.conv, "main.input.consumed", { input_seq: input.seq
|
|
2867
|
+
return this.appendEvent(input.conv, "main.input.consumed", { input_seq: input.seq,
|
|
2868
|
+
...(native ? { input_id: source.input_id, entry_id: native.entry.entryId, session_path: native.entry.sessionPath,
|
|
2869
|
+
durable_session_id: native.durableId, source: mainInputSource(source) } : {}) });
|
|
1154
2870
|
}).immediate();
|
|
1155
2871
|
}
|
|
1156
2872
|
|
|
@@ -1158,7 +2874,7 @@ export class Store {
|
|
|
1158
2874
|
mainUserInput(conv: string, sessionPath: string, text: string, clientInputId?: string): StoredEvent | null {
|
|
1159
2875
|
return this.mainTurn(conv)?.inputs.find((input) => {
|
|
1160
2876
|
const data = input.data as MainInput;
|
|
1161
|
-
return data.origin
|
|
2877
|
+
return data.origin !== "work_update" && data.session_path === sessionPath &&
|
|
1162
2878
|
typeof data.input_id === "string" && (data.native_identity
|
|
1163
2879
|
? data.input_id === clientInputId : data.wire_message === text);
|
|
1164
2880
|
}) ?? null;
|
|
@@ -1169,8 +2885,7 @@ export class Store {
|
|
|
1169
2885
|
if (!turn) return null;
|
|
1170
2886
|
const inputs = turn.inputs.filter((input) => this.mainInputConsumed(input));
|
|
1171
2887
|
const data = ([...inputs].reverse().find((input) => (input.data as MainInput).origin === "user") ?? inputs[0])?.data as MainInput | undefined;
|
|
1172
|
-
if (data
|
|
1173
|
-
if (data?.origin === "work_update") return { origin: "work_update", input_ref: data.input_ref };
|
|
2888
|
+
if (data) return mainInputSource(data);
|
|
1174
2889
|
// Older persisted turn.started events predate the durable input ledger.
|
|
1175
2890
|
return turn.inputs.length === 0 ? turn.source : null;
|
|
1176
2891
|
}
|
|
@@ -1244,6 +2959,11 @@ export class Store {
|
|
|
1244
2959
|
this.db.query("UPDATE conversations SET hidden = ? WHERE id = ?").run(Number(hidden), id);
|
|
1245
2960
|
}
|
|
1246
2961
|
|
|
2962
|
+
/** Binds a conversation to another agent id: an archived agent restored under a new id keeps its history. */
|
|
2963
|
+
setConversationAgent(id: string, agentId: string): void {
|
|
2964
|
+
this.db.query("UPDATE conversations SET agent_id = ? WHERE id = ?").run(agentId, id);
|
|
2965
|
+
}
|
|
2966
|
+
|
|
1247
2967
|
setThread(convId: string, threadId: string): void {
|
|
1248
2968
|
this.db.query("UPDATE conversations SET thread_id = ? WHERE id = ?").run(threadId, convId);
|
|
1249
2969
|
}
|
|
@@ -1549,6 +3269,35 @@ export class Store {
|
|
|
1549
3269
|
}).immediate();
|
|
1550
3270
|
}
|
|
1551
3271
|
|
|
3272
|
+
/**
|
|
3273
|
+
* Bug 282: housekeeping reconcile closes an unfinished work from recorded evidence (`reconcile_*` reason). Nothing changes
|
|
3274
|
+
* unless `expectedSeq` is still the latest command and the work is unfinished, not waiting_input and has no unresolved
|
|
3275
|
+
* decision. In the same transaction every pending/sending/unknown command becomes rejected with the reason as its error,
|
|
3276
|
+
* so it is never re-sent and a later instruct is not refused. The change is an observation, not a terminal report:
|
|
3277
|
+
* it does not wake main, and a report on a newer command still sets the final state.
|
|
3278
|
+
*/
|
|
3279
|
+
closeByReconcile(workId: string, expectedSeq: number, status: "failed" | "cancelled", reason: string, evidence: Record<string, unknown>): WorkItem | null {
|
|
3280
|
+
return this.db.transaction(() => {
|
|
3281
|
+
const work = this.getWork(workId);
|
|
3282
|
+
if (!work || terminalWork(work.status) || work.status === "waiting_input") return null;
|
|
3283
|
+
const commands = this.listWorkCommands(workId);
|
|
3284
|
+
const latest = commands.at(-1);
|
|
3285
|
+
if (latest?.seq !== expectedSeq || this.unresolvedWorkDecisions(workId) > 0) return null;
|
|
3286
|
+
const settled = commands.filter((c) => c.delivery === "pending" || c.delivery === "sending" || c.delivery === "unknown").map((c) => c.id);
|
|
3287
|
+
for (const id of settled) this.setWorkCommandDelivery(id, "rejected", { error: reason });
|
|
3288
|
+
const observation = this.recordWorkUpdate(workId, {
|
|
3289
|
+
sourceKey: `reconcile:${reason}:${latest.id}`,
|
|
3290
|
+
kind: "observation",
|
|
3291
|
+
commandSeq: expectedSeq,
|
|
3292
|
+
summary: reason,
|
|
3293
|
+
details: { status, previousStatus: work.status, previousReason: work.statusReason, evidence, settledCommandIds: settled },
|
|
3294
|
+
});
|
|
3295
|
+
this.db.query("UPDATE work_items SET status = ?, status_reason = ?, updated_at = ? WHERE id = ?")
|
|
3296
|
+
.run(status, reason, observation.receivedAt, workId);
|
|
3297
|
+
return this.getWork(workId);
|
|
3298
|
+
}).immediate();
|
|
3299
|
+
}
|
|
3300
|
+
|
|
1552
3301
|
/** Unresolved decisions (questions, permissions) of one work. */
|
|
1553
3302
|
unresolvedWorkDecisions(workId: string): number {
|
|
1554
3303
|
return this.db.query<{ n: number }, [string]>(`SELECT COUNT(*) AS n FROM decisions WHERE work_id = ? AND ${unresolvedDecisions}`).get(workId)?.n ?? 0;
|
|
@@ -1834,6 +3583,159 @@ export class Store {
|
|
|
1834
3583
|
}).immediate();
|
|
1835
3584
|
}
|
|
1836
3585
|
|
|
3586
|
+
/** 294 permission ledger on this connection (src/agent/tool-permissions.ts). */
|
|
3587
|
+
readonly toolPermissions: AgentToolPermissionStore;
|
|
3588
|
+
|
|
3589
|
+
/** 294 D4: the Aside tab picker's card rebind and bound-call reads on this connection (no second connection). */
|
|
3590
|
+
readonly asideTargets: SqliteAsideDecisions;
|
|
3591
|
+
|
|
3592
|
+
/** Records the tool request and its gateway-owned inbox card (purpose agent_tool_grant) in one transaction. */
|
|
3593
|
+
createAgentToolRequest(input: AgentToolRequestInput): AgentToolRequestRecord {
|
|
3594
|
+
return this.db.transaction(() => {
|
|
3595
|
+
const existing = this.toolPermissions.existingRequest(input);
|
|
3596
|
+
if (existing) return existing;
|
|
3597
|
+
const proposal = this.toolPermissions.proposal(input);
|
|
3598
|
+
const decision = this.createDecision({
|
|
3599
|
+
ownerConv: input.convId, scope: `conv:${input.convId}`, sourceSessionPath: null,
|
|
3600
|
+
sourceRequestId: `agent-tool:${proposal.requestId}`, method: "confirm", permission: false, required: true,
|
|
3601
|
+
payload: proposal.payload, bindingState: "closed",
|
|
3602
|
+
});
|
|
3603
|
+
return this.toolPermissions.insertRequest(input, proposal, decision.id);
|
|
3604
|
+
}).immediate();
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
/**
|
|
3608
|
+
* Q-07: the 294 grant card of a call made inside a collaboration execution. The signed activation must be the
|
|
3609
|
+
* conversation's current binding and the named request the one it executes. Its original input is that execution's
|
|
3610
|
+
* input (a target the owner never messaged has no owner message to name); the card is linked to that execution in
|
|
3611
|
+
* the same transaction, so the request and every request below it wait for the owner while independent ones run.
|
|
3612
|
+
*/
|
|
3613
|
+
createCollaborationToolRequest(input: Omit<AgentToolRequestInput, "originInputSeq">): AgentToolRequestRecord {
|
|
3614
|
+
return this.db.transaction(() => {
|
|
3615
|
+
const binding = this.agentBinding(input.convId);
|
|
3616
|
+
const activation = binding?.activationId ? this.getAgentActivation(binding.activationId) : null;
|
|
3617
|
+
if (!activation || activation.id !== input.activationId || this.agentExecutionRequest(activation)?.id !== input.collaborationRequestId) {
|
|
3618
|
+
throw new AgentToolPermissionError("tool_request_invalid");
|
|
3619
|
+
}
|
|
3620
|
+
const request = this.createAgentToolRequest({ ...input, originInputSeq: activation.inputSeq });
|
|
3621
|
+
if (!this.db.query("SELECT 1 FROM agent_question_links WHERE decision_id=?").get(request.decisionId)) this.linkAgentDecision(request.decisionId, activation.id);
|
|
3622
|
+
return request;
|
|
3623
|
+
}).immediate();
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
/** Q-07: the owner's stored outcome of a collaboration 294 card (null for any other decision or before the answer). */
|
|
3627
|
+
collaborationPermissionOutcome(decisionId: string): CollaborationPermissionOutcome | null {
|
|
3628
|
+
return permissionOutcome(this.db, decisionId);
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
/** Q-07: the answered collaboration 294 cards an execution asked, oldest first. */
|
|
3632
|
+
collaborationPermissionDecisions(activationId: string): string[] {
|
|
3633
|
+
return this.db.query<{ decision_id: string }, [string]>(`SELECT l.decision_id FROM agent_question_links l JOIN agent_tool_outcomes o ON o.decision_id=l.decision_id
|
|
3634
|
+
WHERE l.activation_id=? AND json_extract(o.data,'$.collaborationRequestId') IS NOT NULL ORDER BY l.rowid`).all(activationId).map(row => row.decision_id);
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
/**
|
|
3638
|
+
* 294 C8: the one-shot confirmation card (purpose agent_tool_action) of a click/fill on a granted target, with the request
|
|
3639
|
+
* row, in one transaction. The same logical call returns the same card. Without an active grant naming the operation
|
|
3640
|
+
* nothing is recorded (grant_conflict): an action card never stands in for the tool grant itself.
|
|
3641
|
+
*/
|
|
3642
|
+
createAgentToolAction(input: AgentToolRequestInput): AgentToolRequestRecord {
|
|
3643
|
+
return this.db.transaction(() => {
|
|
3644
|
+
const existing = this.toolPermissions.existingRequest(input);
|
|
3645
|
+
if (existing) return existing;
|
|
3646
|
+
if (!this.toolPermissions.actionGrant(input.agentId, input.toolKey, input.operation)) throw new AgentToolPermissionError("grant_conflict");
|
|
3647
|
+
const proposal = this.toolPermissions.proposal(input, "agent_tool_action");
|
|
3648
|
+
const decision = this.createDecision({
|
|
3649
|
+
ownerConv: input.convId, scope: `conv:${input.convId}`, sourceSessionPath: null,
|
|
3650
|
+
sourceRequestId: `agent-tool-action:${proposal.requestId}`, method: "confirm", permission: false, required: true,
|
|
3651
|
+
payload: proposal.payload, bindingState: "closed",
|
|
3652
|
+
});
|
|
3653
|
+
return this.toolPermissions.insertRequest(input, proposal, decision.id, "agent_tool_action");
|
|
3654
|
+
}).immediate();
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
/**
|
|
3658
|
+
* 294 C5: an execution whose outcome cannot be proven (started without a stored result, a lost execute answer, an engine
|
|
3659
|
+
* that reported unknown) is marked outcome_unknown and shown once on the existing needs_review surface with the original
|
|
3660
|
+
* operation and its state. Nothing is re-run; the owner decides what to do next. The same execution keeps one card.
|
|
3661
|
+
*/
|
|
3662
|
+
reviewAgentToolOutcome(invocationId: string, reason: string): Decision | null {
|
|
3663
|
+
return this.db.transaction(() => {
|
|
3664
|
+
const target = this.toolPermissions.deliveryTarget(invocationId);
|
|
3665
|
+
if (!target) return null;
|
|
3666
|
+
this.toolPermissions.markOutcomeUnknown(invocationId);
|
|
3667
|
+
const source = { scope: `conv:${target.convId}`, sourceSessionPath: null, sourceRequestId: `agent-tool-outcome:${invocationId}` };
|
|
3668
|
+
const existing = this.getDecisionBySource(source);
|
|
3669
|
+
if (existing) return existing;
|
|
3670
|
+
const request = this.db.query<{ tool_key_json: string; operation: string }, [string]>(
|
|
3671
|
+
"SELECT r.tool_key_json, r.operation FROM agent_tool_invocations i JOIN agent_tool_requests r ON r.id = i.request_id WHERE i.id = ?").get(invocationId)!;
|
|
3672
|
+
const toolKey = JSON.parse(request.tool_key_json) as { originalName?: unknown };
|
|
3673
|
+
const tool = typeof toolKey.originalName === "string" ? toolKey.originalName : "tool";
|
|
3674
|
+
return this.createDecision({
|
|
3675
|
+
ownerConv: target.convId, ...source, method: "confirm", permission: false, required: true, bindingState: "unknown",
|
|
3676
|
+
payload: {
|
|
3677
|
+
kind: "agent_tool_outcome_unknown", title: `Tool result unknown: ${tool} ${request.operation}`,
|
|
3678
|
+
message: "The tool may have run, but its result was not recorded. It will not run again by itself. Check the target before asking again.",
|
|
3679
|
+
agentId: target.agentId, invocationId, operationId: target.operationId, toolKey, operation: request.operation,
|
|
3680
|
+
state: "outcome_unknown", reason,
|
|
3681
|
+
},
|
|
3682
|
+
});
|
|
3683
|
+
}).immediate();
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
/**
|
|
3687
|
+
* 294 item 11: the migration card of a legacy agent (purpose agent_tool_migration) in one transaction. The open card is
|
|
3688
|
+
* returned again (one card per agent and lifecycle); a managed agent gets none. Creating it grants nothing.
|
|
3689
|
+
*/
|
|
3690
|
+
createAgentToolMigration(agentId: string, convId: string, items: readonly LegacyScopeItem[]): AgentToolMigrationCard | null {
|
|
3691
|
+
return this.db.transaction(() => {
|
|
3692
|
+
const current = this.toolPermissions.currentMigration(agentId);
|
|
3693
|
+
if (current && current.state !== "applied") return current;
|
|
3694
|
+
if (this.toolPermissions.mode(agentId) === "managed") return null;
|
|
3695
|
+
const proposal = this.toolPermissions.migrationProposal(agentId, items);
|
|
3696
|
+
const decision = this.createDecision({
|
|
3697
|
+
ownerConv: convId, scope: `conv:${convId}`, sourceSessionPath: null,
|
|
3698
|
+
sourceRequestId: `agent-tool-migration:${agentId}:${crypto.randomUUID()}`, method: "confirm", permission: false, required: true,
|
|
3699
|
+
payload: proposal.payload, bindingState: "closed",
|
|
3700
|
+
});
|
|
3701
|
+
return this.toolPermissions.insertMigration(agentId, convId, items, proposal, decision.id);
|
|
3702
|
+
}).immediate();
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
/** 294 grant scope edit: a narrowing applies at once; a widening opens its agent_tool_grant inbox card. One transaction. */
|
|
3706
|
+
editAgentToolGrant(agentId: string, grantId: string, input: GrantEditInput, principal: OwnerPrincipal): GrantEditResult {
|
|
3707
|
+
return this.db.transaction(() => this.toolPermissions.editGrant(agentId, grantId, input, principal, (ownerConv, sourceRequestId, payload) => this.createDecision({
|
|
3708
|
+
ownerConv, scope: `conv:${ownerConv}`, sourceSessionPath: null, sourceRequestId, method: "confirm", permission: false, required: true,
|
|
3709
|
+
payload, bindingState: "closed",
|
|
3710
|
+
}).id)).immediate();
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
/**
|
|
3714
|
+
* The owner answer to a tool purpose decision: answer, grant, policy revision and execution intent commit together.
|
|
3715
|
+
* The same submission with the same body returns the stored result; another body is submission_conflict.
|
|
3716
|
+
*/
|
|
3717
|
+
answerAgentToolDecision(id: string, request: AgentToolGrantAnswer, principal: OwnerPrincipal): { decision: Decision; fresh: boolean; result: AnswerResult | null } {
|
|
3718
|
+
return this.db.transaction(() => {
|
|
3719
|
+
const decision = this.getDecision(id);
|
|
3720
|
+
if (!decision) throw new DecisionStoreError("decision_not_found");
|
|
3721
|
+
if (!this.toolPermissions.isToolPurpose(id)) throw new DecisionStoreError("invalid_decision_answer", decision);
|
|
3722
|
+
const previous = this.decisionOperation(request.submissionId);
|
|
3723
|
+
if (previous) {
|
|
3724
|
+
if (previous.data.decisionId !== id || !isDeepStrictEqual(previous.data.request, request)) throw new DecisionStoreError("submission_conflict", decision);
|
|
3725
|
+
return { decision, fresh: false, result: null };
|
|
3726
|
+
}
|
|
3727
|
+
if (decision.revision !== request.expectedRevision || decision.status !== "pending" || decision.submissionId !== null) {
|
|
3728
|
+
throw new DecisionStoreError("decision_conflict", decision);
|
|
3729
|
+
}
|
|
3730
|
+
const result = this.toolPermissions.answerToolDecision(id, decision.revision + 1, request, principal);
|
|
3731
|
+
this.db.query(`UPDATE decisions SET status = 'closed', reason = ?, revision = revision + 1, submission_id = ?, submission_revision = ?,
|
|
3732
|
+
submission_mode = 'answer', answer = ?, updated_at = ? WHERE id = ?`)
|
|
3733
|
+
.run(`tool_grant_${result.outcome}`, request.submissionId, request.expectedRevision, JSON.stringify(request.response), new Date().toISOString(), id);
|
|
3734
|
+
this.appendEvent(decision.ownerConv, "decision.operation", { decisionId: id, request, targetScope: decision.scope, commandId: null, mainInputSeq: null });
|
|
3735
|
+
return { decision: this.getDecision(id)!, fresh: true, result };
|
|
3736
|
+
}).immediate();
|
|
3737
|
+
}
|
|
3738
|
+
|
|
1837
3739
|
getDecision(id: string): Decision | null {
|
|
1838
3740
|
const row = this.db.query<DecisionRow, [string]>(`SELECT ${decisionColumns} FROM decisions WHERE id = ?`).get(id);
|
|
1839
3741
|
return row ? toDecision(row) : null;
|
|
@@ -1882,7 +3784,7 @@ export class Store {
|
|
|
1882
3784
|
if (!work) return "owner_unknown";
|
|
1883
3785
|
if (terminalWork(work.status)) return `work_${work.status}`;
|
|
1884
3786
|
if (decision.sourceSessionPath !== work.sessionPath || decision.sourceCommandSeq === null) return "owner_unknown";
|
|
1885
|
-
if (blockedQuestion(decision)) return null;
|
|
3787
|
+
if (blockedQuestion(decision) || releasedWorkQuestion(decision)) return null;
|
|
1886
3788
|
const newer = this.listWorkCommands(work.id).some((command) =>
|
|
1887
3789
|
command.kind === "instruct" && command.seq > decision.sourceCommandSeq! &&
|
|
1888
3790
|
(!object(command.payload) || !Object.hasOwn(command.payload, "decision_operation")));
|
|
@@ -1909,6 +3811,7 @@ export class Store {
|
|
|
1909
3811
|
const result = this.db.transaction((): DecisionClaim | DecisionStoreError => {
|
|
1910
3812
|
let decision = this.getDecision(id);
|
|
1911
3813
|
if (!decision) throw new DecisionStoreError("decision_not_found");
|
|
3814
|
+
if (collaborationStageDecision(decision)) throw new CollaborationStoreError("collaboration_stage_endpoint_required", 403);
|
|
1912
3815
|
if (securityDecision(decision)) throw new DecisionStoreError("security_endpoint_required");
|
|
1913
3816
|
if (!Number.isSafeInteger(request.expectedRevision) || request.expectedRevision < 1 ||
|
|
1914
3817
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(request.submissionId)) {
|
|
@@ -1932,6 +3835,7 @@ export class Store {
|
|
|
1932
3835
|
decision = this.submitDecision(id, { ...request, liveBinding });
|
|
1933
3836
|
return { decision, operation: null, fresh: false };
|
|
1934
3837
|
}
|
|
3838
|
+
if (this.toolPermissions.isToolPurpose(id)) throw new DecisionStoreError("tool_grant_response_required", decision);
|
|
1935
3839
|
if (decision.revision !== request.expectedRevision || decision.status === "closed" || decision.status === "delivered") {
|
|
1936
3840
|
throw new DecisionStoreError("decision_conflict", decision);
|
|
1937
3841
|
}
|
|
@@ -1965,6 +3869,9 @@ export class Store {
|
|
|
1965
3869
|
let mainInputSeq: number | null = null;
|
|
1966
3870
|
const work = decision.workId === null ? null : this.getWork(decision.workId);
|
|
1967
3871
|
const knownWork = work !== null && decision.sourceSessionPath === work.sessionPath && decision.sourceCommandSeq !== null;
|
|
3872
|
+
const linkedId = decision.permission ? null : this.db.query<{ request_id: string | null }, [string]>(
|
|
3873
|
+
"SELECT request_id FROM agent_question_links WHERE decision_id=? AND transfer_id IS NULL AND read_operation_id IS NULL").get(decision.id)?.request_id ?? null;
|
|
3874
|
+
const linked = linkedId ? this.getAgentRequest(linkedId) : null;
|
|
1968
3875
|
const targetScope = knownWork ? decision.scope : `conv:${decision.ownerConv}`;
|
|
1969
3876
|
if (control || decision.answerRoute === "follow_up_command") {
|
|
1970
3877
|
const operation = request.mode === "resume" ? "answer" : request.mode;
|
|
@@ -1984,6 +3891,15 @@ export class Store {
|
|
|
1984
3891
|
kind: "instruct", requestKey: `decision:${request.submissionId}`,
|
|
1985
3892
|
sourceSessionPath: work.sessionPath, payload,
|
|
1986
3893
|
}).id;
|
|
3894
|
+
} else if (!control && linked) {
|
|
3895
|
+
// The owner's answer resumes the request that asked, through its own continuation, never a plain owner turn.
|
|
3896
|
+
// A request that already ended keeps the answer in history only and wakes no model.
|
|
3897
|
+
const message = this.appendEvent(decision.ownerConv, "message.collaboration.answer", {
|
|
3898
|
+
text: `ZGW_DECISION_COMMAND ${JSON.stringify(payload)}`, src: { channel: "web", adapter: "decision-inbox" }, trust: "owner",
|
|
3899
|
+
decision_id: decision.id, decision_operation: operation, requestId: linked.id,
|
|
3900
|
+
});
|
|
3901
|
+
const open = !agentRequestTerminal(linked.state) && linked.state !== "cancel_requested";
|
|
3902
|
+
commandId = open ? `collaboration:${this.newAgentActivation(linked, "owner_answer", message.seq).id}` : "collaboration:ended";
|
|
1987
3903
|
} else {
|
|
1988
3904
|
const input = this.acceptMainUser(decision.ownerConv, {
|
|
1989
3905
|
text: `ZGW_DECISION_COMMAND ${JSON.stringify(payload)}`,
|
|
@@ -2000,6 +3916,7 @@ export class Store {
|
|
|
2000
3916
|
decision = this.getDecision(id)!;
|
|
2001
3917
|
}
|
|
2002
3918
|
}
|
|
3919
|
+
if (commandId === "collaboration:ended") decision = this.transitionDecision(id, decision.revision, { status: "closed", reason: "collaboration_request_ended" });
|
|
2003
3920
|
const data: DecisionOperation = { decisionId: id, request, targetScope, commandId, mainInputSeq };
|
|
2004
3921
|
const operation = this.appendEvent(decision.ownerConv, "decision.operation", data) as DecisionOperationEvent;
|
|
2005
3922
|
return { decision, operation, fresh: true };
|
|
@@ -2378,6 +4295,7 @@ export class Store {
|
|
|
2378
4295
|
private decisionAtRevision(id: string, revision: number): Decision {
|
|
2379
4296
|
const decision = this.getDecision(id);
|
|
2380
4297
|
if (!decision) throw new DecisionStoreError("decision_not_found");
|
|
4298
|
+
if (collaborationStageDecision(decision)) throw new CollaborationStoreError("collaboration_stage_endpoint_required", 403);
|
|
2381
4299
|
if (decision.revision !== revision) throw new DecisionStoreError("decision_conflict", decision);
|
|
2382
4300
|
return decision;
|
|
2383
4301
|
}
|
|
@@ -2487,6 +4405,7 @@ export class Store {
|
|
|
2487
4405
|
return this.db.transaction(() => {
|
|
2488
4406
|
const d = this.getDecision(id);
|
|
2489
4407
|
if (!d) throw new DecisionStoreError("decision_not_found");
|
|
4408
|
+
if (collaborationStageDecision(d) || securityDecision(d)) throw new CollaborationStoreError("purpose_endpoint_required", 403);
|
|
2490
4409
|
const prior = this.db.query<{ id: string }, [string]>("SELECT id FROM decisions WHERE submission_id = ?").get(request.submissionId);
|
|
2491
4410
|
if (prior) {
|
|
2492
4411
|
if (prior.id !== id || d.submissionRevision !== request.expectedRevision || d.submissionMode !== request.mode || !isDeepStrictEqual(d.answer, request.response)) {
|
|
@@ -2560,6 +4479,7 @@ export class Store {
|
|
|
2560
4479
|
return this.db.transaction((): DecisionDismissal => {
|
|
2561
4480
|
const d = this.getDecision(id);
|
|
2562
4481
|
if (!d) throw new DecisionStoreError("decision_not_found");
|
|
4482
|
+
if (collaborationStageDecision(d)) throw new CollaborationStoreError("collaboration_stage_endpoint_required", 403);
|
|
2563
4483
|
if (securityDecision(d)) throw new DecisionStoreError("security_endpoint_required");
|
|
2564
4484
|
if (d.revision !== expectedRevision || d.status === "delivered" || d.status === "closed" || d.status === "submitted") {
|
|
2565
4485
|
throw new DecisionStoreError("decision_conflict", d);
|
|
@@ -2721,6 +4641,9 @@ export class Store {
|
|
|
2721
4641
|
}
|
|
2722
4642
|
|
|
2723
4643
|
close(): void {
|
|
4644
|
+
// A late reply wake or flush after close must not read this database: nothing is left to publish and nobody is called.
|
|
4645
|
+
this.collaborationOutbox.clear();
|
|
4646
|
+
this.onCollaborationEvents = null;
|
|
2724
4647
|
this.db.close();
|
|
2725
4648
|
}
|
|
2726
4649
|
}
|