@happyvertical/smrt-ingestion 0.57.20 → 0.57.22
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/FEEDBACK.md +137 -0
- package/PROPOSALS.md +146 -5
- package/README.md +168 -2
- package/REVIEW.md +611 -7
- package/agents/execution.md +87 -0
- package/dist/chunks/{execution-NoDh3pjs.js → execution-BzVQax0n.js} +528 -42
- package/dist/chunks/execution-BzVQax0n.js.map +1 -0
- package/dist/chunks/{feedback-CJB900iG.js → feedback-D3UTF8fm.js} +175 -36
- package/dist/chunks/feedback-D3UTF8fm.js.map +1 -0
- package/dist/chunks/{proposal-errors-VxcTU6Db.js → proposal-contracts-77Uo1B1j.js} +38 -44
- package/dist/chunks/proposal-contracts-77Uo1B1j.js.map +1 -0
- package/dist/chunks/reviewer-instruction-CEc3AOuU.js +33 -0
- package/dist/chunks/reviewer-instruction-CEc3AOuU.js.map +1 -0
- package/dist/dto.d.ts +75 -0
- package/dist/dto.d.ts.map +1 -1
- package/dist/execution-contracts.d.ts +4 -1
- package/dist/execution-contracts.d.ts.map +1 -1
- package/dist/execution-dto.d.ts +16 -0
- package/dist/execution-dto.d.ts.map +1 -1
- package/dist/execution-internal.d.ts +2 -0
- package/dist/execution-internal.d.ts.map +1 -1
- package/dist/execution.d.ts +101 -4
- package/dist/execution.d.ts.map +1 -1
- package/dist/execution.js +2 -2
- package/dist/feedback-dto.d.ts +18 -4
- package/dist/feedback-dto.d.ts.map +1 -1
- package/dist/feedback-internal.d.ts +9 -0
- package/dist/feedback-internal.d.ts.map +1 -1
- package/dist/feedback.d.ts +10 -0
- package/dist/feedback.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/manifest.json +339 -3
- package/dist/models.d.ts +15 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +82 -2
- package/dist/models.js.map +1 -1
- package/dist/policy.d.ts +3 -0
- package/dist/policy.d.ts.map +1 -1
- package/dist/presented-filename.d.ts +11 -0
- package/dist/presented-filename.d.ts.map +1 -0
- package/dist/proposal-contracts.d.ts +31 -6
- package/dist/proposal-contracts.d.ts.map +1 -1
- package/dist/proposal-dto.d.ts +17 -0
- package/dist/proposal-dto.d.ts.map +1 -1
- package/dist/proposal-errors.d.ts +20 -0
- package/dist/proposal-errors.d.ts.map +1 -1
- package/dist/proposal-validation.d.ts +26 -0
- package/dist/proposal-validation.d.ts.map +1 -1
- package/dist/proposals.d.ts +6 -1
- package/dist/proposals.d.ts.map +1 -1
- package/dist/proposals.js +268 -71
- package/dist/proposals.js.map +1 -1
- package/dist/redaction.d.ts +9 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/review-dto.d.ts +108 -5
- package/dist/review-dto.d.ts.map +1 -1
- package/dist/review-host.d.ts +52 -3
- package/dist/review-host.d.ts.map +1 -1
- package/dist/review-instruction-view.d.ts +12 -0
- package/dist/review-instruction-view.d.ts.map +1 -0
- package/dist/review-summary.d.ts.map +1 -1
- package/dist/reviewer-instruction.d.ts +20 -0
- package/dist/reviewer-instruction.d.ts.map +1 -0
- package/dist/server.d.ts +252 -13
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1190 -85
- package/dist/server.js.map +1 -1
- package/dist/smrt-knowledge.json +137 -23
- package/dist/sources/common.d.ts +11 -0
- package/dist/sources/common.d.ts.map +1 -1
- package/dist/sources/email.d.ts.map +1 -1
- package/dist/sources/http.d.ts.map +1 -1
- package/dist/sources/watch-folder.d.ts.map +1 -1
- package/dist/svelte/components/ActionReview.svelte +54 -55
- package/dist/svelte/components/ActionReview.svelte.d.ts +8 -1
- package/dist/svelte/components/ActionReview.svelte.d.ts.map +1 -1
- package/dist/svelte/components/CandidatePicker.svelte +72 -0
- package/dist/svelte/components/CandidatePicker.svelte.d.ts +15 -0
- package/dist/svelte/components/CandidatePicker.svelte.d.ts.map +1 -0
- package/dist/svelte/components/EvidenceViewer.svelte +60 -4
- package/dist/svelte/components/EvidenceViewer.svelte.d.ts +12 -1
- package/dist/svelte/components/EvidenceViewer.svelte.d.ts.map +1 -1
- package/dist/svelte/components/InboxView.svelte +172 -19
- package/dist/svelte/components/InboxView.svelte.d.ts +36 -2
- package/dist/svelte/components/InboxView.svelte.d.ts.map +1 -1
- package/dist/svelte/components/IntakeInbox.svelte +6 -3
- package/dist/svelte/components/IntakeInbox.svelte.d.ts +4 -1
- package/dist/svelte/components/IntakeInbox.svelte.d.ts.map +1 -1
- package/dist/svelte/components/IntakeReview.svelte +4 -2
- package/dist/svelte/components/IntakeReview.svelte.d.ts +2 -0
- package/dist/svelte/components/IntakeReview.svelte.d.ts.map +1 -1
- package/dist/svelte/components/IntakeWorkspace.svelte +27 -0
- package/dist/svelte/components/IntakeWorkspace.svelte.d.ts +19 -0
- package/dist/svelte/components/IntakeWorkspace.svelte.d.ts.map +1 -0
- package/dist/svelte/components/ReviewView.svelte +653 -71
- package/dist/svelte/components/ReviewView.svelte.d.ts +20 -1
- package/dist/svelte/components/ReviewView.svelte.d.ts.map +1 -1
- package/dist/svelte/components/TriageView.svelte +669 -0
- package/dist/svelte/components/TriageView.svelte.d.ts +16 -0
- package/dist/svelte/components/TriageView.svelte.d.ts.map +1 -0
- package/dist/svelte/i18n.d.ts +56 -0
- package/dist/svelte/i18n.d.ts.map +1 -1
- package/dist/svelte/i18n.js +56 -0
- package/dist/svelte/index.d.ts +2 -1
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +1 -0
- package/dist/svelte/view-utils.d.ts +67 -0
- package/dist/svelte/view-utils.d.ts.map +1 -1
- package/dist/svelte/view-utils.js +157 -0
- package/package.json +13 -13
- package/dist/chunks/execution-NoDh3pjs.js.map +0 -1
- package/dist/chunks/execution-internal-DWteDHM2.js +0 -7
- package/dist/chunks/execution-internal-DWteDHM2.js.map +0 -1
- package/dist/chunks/feedback-CJB900iG.js.map +0 -1
- package/dist/chunks/proposal-errors-VxcTU6Db.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
|
+
import { _ as listReadGate, d as GenerationSnapshotStaleError, f as GenerationSourceStaleError, g as generationSnapshotRead, l as UNFINISHED_ACTION_STATES, m as ProposalsNotConfiguredError, p as HandlerArgumentsInvalidError, u as redactedCeiling } from "./chunks/execution-BzVQax0n.js";
|
|
1
2
|
import "./models.js";
|
|
2
|
-
import {
|
|
3
|
-
import { a as
|
|
3
|
+
import { r as extractWithProviders } from "./chunks/extraction-providers-C66ADDVe.js";
|
|
4
|
+
import { a as proposeDocumentSplits, i as extractAnalysis, n as assertProposalLimits, r as createSDKExtractionAdapter, t as proposalReadConfiguration } from "./chunks/proposal-contracts-77Uo1B1j.js";
|
|
5
|
+
import { i as reviewerInstructionView, n as ReviewerInstructionInvalidError, r as ReviewerInstructionStaleError, t as REVIEWER_INSTRUCTION_MAX_LENGTH } from "./chunks/reviewer-instruction-CEc3AOuU.js";
|
|
4
6
|
import { detectEngine, isEmbeddedDatabase, isTransientDatabaseError, withEmbeddedWriteTransaction } from "@happyvertical/smrt-core";
|
|
5
7
|
import { withTenant } from "@happyvertical/smrt-tenancy";
|
|
6
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
|
|
7
9
|
import { Asset } from "@happyvertical/smrt-assets";
|
|
8
10
|
import { SmrtJobCollection } from "@happyvertical/smrt-jobs";
|
|
9
11
|
import { join } from "node:path";
|
|
10
12
|
import { constants } from "node:fs";
|
|
11
13
|
import { lstat, mkdir, open, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
14
|
+
//#region src/presented-filename.ts
|
|
15
|
+
var UNSAFE = "[\\p{Cc}\\p{Cf}\\p{Cs}\\p{Zl}\\p{Zp}\\p{Default_Ignorable_Code_Point}]";
|
|
16
|
+
var UNSAFE_CHARACTER = new RegExp(UNSAFE, "u");
|
|
17
|
+
var UNSAFE_CHARACTERS = new RegExp(UNSAFE, "gu");
|
|
18
|
+
function isStorableFilename(value) {
|
|
19
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= 512 && !UNSAFE_CHARACTER.test(value);
|
|
20
|
+
}
|
|
21
|
+
function presentedFilename(value) {
|
|
22
|
+
if (typeof value !== "string") return void 0;
|
|
23
|
+
const name = value.replace(UNSAFE_CHARACTERS, "").trim();
|
|
24
|
+
return isStorableFilename(name) ? name : void 0;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
12
27
|
//#region src/review-analysis.ts
|
|
13
28
|
async function loadReviewAnalysis(service, item, reviews, cursor) {
|
|
14
29
|
if (!["completed", "partial"].includes(item.processingState) || reviews.generationStale) return { reviews };
|
|
@@ -23,6 +38,28 @@ async function loadReviewAnalysis(service, item, reviews, cursor) {
|
|
|
23
38
|
}
|
|
24
39
|
}
|
|
25
40
|
//#endregion
|
|
41
|
+
//#region src/review-instruction-view.ts
|
|
42
|
+
async function loadInstructionView(service, itemId, analysis) {
|
|
43
|
+
let [latest] = await service.listReviewerInstructions(itemId);
|
|
44
|
+
const applied = analysis?.configuration.stage === "interpret" && analysis.configuration.reviewerInstruction ? reviewerInstructionView(analysis.configuration.reviewerInstruction) : null;
|
|
45
|
+
if (!latest && !applied) return void 0;
|
|
46
|
+
const state = await service.getReinterpretationState(itemId);
|
|
47
|
+
if (state.instructionInEffect !== null && state.instructionInEffect !== latest?.id) [latest] = await service.listReviewerInstructions(itemId);
|
|
48
|
+
const current = latest && latest.id === state.instructionInEffect ? latest : null;
|
|
49
|
+
return {
|
|
50
|
+
current,
|
|
51
|
+
applied,
|
|
52
|
+
stale: !!analysis && !!current && current.id !== applied?.id,
|
|
53
|
+
...latest && state.instructionInEffect === null ? { dropped: latest } : {},
|
|
54
|
+
...state.failure ? { failure: state.failure } : {},
|
|
55
|
+
...state.reinterpreting ? { reinterpreting: true } : {},
|
|
56
|
+
...analysis?.configuration.stage !== "interpret" && state.generationAttemptId ? { binding: {
|
|
57
|
+
attemptId: state.generationAttemptId,
|
|
58
|
+
expectedRevision: state.revision
|
|
59
|
+
} } : {}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
26
63
|
//#region src/review-summary.ts
|
|
27
64
|
async function loadReviewSummary(service, item, requestedCursor) {
|
|
28
65
|
let requested;
|
|
@@ -45,7 +82,7 @@ async function loadReviewSummary(service, item, requestedCursor) {
|
|
|
45
82
|
} while (cursor);
|
|
46
83
|
const current = await service.getItem(item.id);
|
|
47
84
|
if (!requested) throw new Error("Review page unavailable");
|
|
48
|
-
const state = total ? succeeded === total ? "completed" : succeeded ? "partially_completed" : deferred ? "deferred" : failed ? "failed" : "waiting" :
|
|
85
|
+
const state = current.disposition && !succeeded ? "resolved" : ["queued", "running"].includes(current.processingState) ? "processing" : total ? succeeded === total ? "completed" : succeeded ? "partially_completed" : deferred ? "deferred" : failed ? "failed" : "waiting" : current.processingState === "needs_attention" ? "failed" : "unresolved";
|
|
49
86
|
return {
|
|
50
87
|
reviews: requested,
|
|
51
88
|
state
|
|
@@ -53,31 +90,139 @@ async function loadReviewSummary(service, item, requestedCursor) {
|
|
|
53
90
|
}
|
|
54
91
|
//#endregion
|
|
55
92
|
//#region src/review-host.ts
|
|
93
|
+
function primaryEvidence(parts) {
|
|
94
|
+
const depth = (part) => {
|
|
95
|
+
let level = 0;
|
|
96
|
+
let parent = part.parentEvidenceId;
|
|
97
|
+
const seen = /* @__PURE__ */ new Set();
|
|
98
|
+
while (parent && !seen.has(parent)) {
|
|
99
|
+
seen.add(parent);
|
|
100
|
+
level++;
|
|
101
|
+
parent = parts.find((p) => p.id === parent)?.parentEvidenceId ?? null;
|
|
102
|
+
}
|
|
103
|
+
return level;
|
|
104
|
+
};
|
|
105
|
+
const ordered = [...parts].sort((a, b) => depth(a) - depth(b) || a.partId.localeCompare(b.partId, "en", { numeric: true }));
|
|
106
|
+
return ordered.find((part) => depth(part) > 0) ?? ordered[0];
|
|
107
|
+
}
|
|
56
108
|
function createIntakeReviewHost(service, options) {
|
|
57
|
-
const label = options.label ?? ((item) => `Intake ${item.id.slice(0, 8)}`);
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
109
|
+
const label = options.label ?? ((item, evidence) => primaryEvidence(evidence)?.filename ?? `Intake ${item.id.slice(0, 8)}`);
|
|
110
|
+
const labelled = (parts) => {
|
|
111
|
+
const primary = primaryEvidence(parts);
|
|
112
|
+
return primary ? [primary, ...parts.filter((part) => part !== primary)] : [...parts];
|
|
113
|
+
};
|
|
114
|
+
const decorateRows = async (entries) => {
|
|
115
|
+
if (!entries.length) return entries;
|
|
116
|
+
let parts;
|
|
117
|
+
try {
|
|
118
|
+
parts = await service.listEvidence(entries.map((entry) => entry.item.id));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (options.rowEvidence) throw error;
|
|
121
|
+
return entries.map((entry) => ({
|
|
122
|
+
...entry,
|
|
123
|
+
label: label(entry.item, [])
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
const named = entries.map((entry) => ({
|
|
127
|
+
...entry,
|
|
128
|
+
label: label(entry.item, labelled(parts.get(entry.item.id) ?? []))
|
|
129
|
+
}));
|
|
130
|
+
if (!options.rowEvidence) return named;
|
|
131
|
+
const rows = [];
|
|
132
|
+
for (const entry of named) {
|
|
133
|
+
const primary = primaryEvidence(parts.get(entry.item.id) ?? []);
|
|
134
|
+
if (primary) rows.push({
|
|
135
|
+
itemId: entry.item.id,
|
|
136
|
+
evidence: primary
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (!rows.length) return named;
|
|
140
|
+
const primaries = new Map(rows.map((row) => [row.itemId, row.evidence]));
|
|
141
|
+
try {
|
|
142
|
+
const views = await options.rowEvidence(rows);
|
|
143
|
+
return named.map((entry) => {
|
|
144
|
+
const primary = primaries.get(entry.item.id);
|
|
145
|
+
if (!primary) return entry;
|
|
146
|
+
const view = views.get(entry.item.id);
|
|
147
|
+
if (!view) return entry;
|
|
148
|
+
if (typeof view.label !== "string") throw new Error("Invalid row evidence");
|
|
149
|
+
const evidence = {
|
|
150
|
+
evidence: primary,
|
|
151
|
+
label: view.label
|
|
152
|
+
};
|
|
153
|
+
if (typeof view.viewUrl === "string") evidence.viewUrl = view.viewUrl;
|
|
154
|
+
if (typeof view.thumbnailUrl === "string") evidence.thumbnailUrl = view.thumbnailUrl;
|
|
155
|
+
return {
|
|
156
|
+
...entry,
|
|
157
|
+
evidence
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
} catch {
|
|
161
|
+
return named;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const assignments = async (itemIds) => {
|
|
165
|
+
if (!itemIds.length) return /* @__PURE__ */ new Map();
|
|
166
|
+
if (options.assignments) return options.assignments(itemIds);
|
|
167
|
+
const read = /* @__PURE__ */ new Map();
|
|
168
|
+
for (const itemId of itemIds) read.set(itemId, await options.assignment(itemId));
|
|
169
|
+
return read;
|
|
68
170
|
};
|
|
69
171
|
return {
|
|
70
172
|
async list(input) {
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
173
|
+
const requested = input.limit ?? 20;
|
|
174
|
+
const limit = Number.isNaN(requested) ? 20 : Math.min(100, Math.max(1, Math.floor(requested)));
|
|
175
|
+
let itemIds;
|
|
176
|
+
if (input.assigneeId && options.assignedItemIds) {
|
|
177
|
+
const assigned = [...new Set((await options.assignedItemIds(input.assigneeId)).filter(isIntakeId))];
|
|
178
|
+
if (assigned.length <= 1e3) itemIds = assigned;
|
|
179
|
+
}
|
|
180
|
+
const items = [];
|
|
181
|
+
let cursor = input.cursor;
|
|
182
|
+
let examined = 0;
|
|
183
|
+
for (;;) {
|
|
184
|
+
let page;
|
|
185
|
+
try {
|
|
186
|
+
page = await service.listItemsPage({
|
|
187
|
+
limit,
|
|
188
|
+
cursor,
|
|
189
|
+
state: input.state,
|
|
190
|
+
itemIds,
|
|
191
|
+
scanLimit: ITEM_SCAN_CEILING - examined
|
|
192
|
+
});
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error instanceof IntakeScanLimitError && cursor !== input.cursor) return {
|
|
195
|
+
items: await decorateRows(items),
|
|
196
|
+
...cursor ? { nextCursor: cursor } : {}
|
|
197
|
+
};
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
examined += page.scanned;
|
|
201
|
+
const read = await assignments(page.items.map(({ item }) => item.id));
|
|
202
|
+
for (const [index, { item, state, cursor: rowCursor }] of page.items.entries()) {
|
|
203
|
+
const assignment = read.get(item.id) ?? {
|
|
204
|
+
assigneeId: null,
|
|
205
|
+
version: 0
|
|
206
|
+
};
|
|
207
|
+
if (input.assigneeId && assignment.assigneeId !== input.assigneeId) continue;
|
|
208
|
+
items.push({
|
|
209
|
+
item,
|
|
210
|
+
label: "",
|
|
211
|
+
state,
|
|
212
|
+
assignment
|
|
213
|
+
});
|
|
214
|
+
if (items.length === limit) return {
|
|
215
|
+
items: await decorateRows(items),
|
|
216
|
+
...page.nextCursor || index < page.items.length - 1 ? { nextCursor: rowCursor } : {}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (!page.nextCursor) return { items: await decorateRows(items) };
|
|
220
|
+
if (page.items.length < limit || examined >= 1e3) return {
|
|
221
|
+
items: await decorateRows(items),
|
|
222
|
+
nextCursor: page.nextCursor
|
|
223
|
+
};
|
|
224
|
+
cursor = page.nextCursor;
|
|
76
225
|
}
|
|
77
|
-
return {
|
|
78
|
-
items: projected,
|
|
79
|
-
...items.length > 20 ? { nextCursor: items[19].id } : {}
|
|
80
|
-
};
|
|
81
226
|
},
|
|
82
227
|
async load(itemId, cursor) {
|
|
83
228
|
const item = await service.getItem(itemId);
|
|
@@ -89,30 +234,47 @@ function createIntakeReviewHost(service, options) {
|
|
|
89
234
|
evidence: part
|
|
90
235
|
});
|
|
91
236
|
const assignment = await options.assignment(itemId);
|
|
237
|
+
let catalog;
|
|
238
|
+
try {
|
|
239
|
+
catalog = await service.listHandlers(itemId);
|
|
240
|
+
} catch {
|
|
241
|
+
catalog = void 0;
|
|
242
|
+
}
|
|
243
|
+
let disposition;
|
|
244
|
+
if (item.disposition) try {
|
|
245
|
+
disposition = await service.getItemDisposition(itemId);
|
|
246
|
+
} catch {
|
|
247
|
+
disposition = void 0;
|
|
248
|
+
}
|
|
249
|
+
const instruction = await loadInstructionView(service, itemId, analysis);
|
|
92
250
|
await service.getItem(itemId);
|
|
93
251
|
return {
|
|
94
252
|
entry: {
|
|
95
253
|
item,
|
|
96
|
-
label: label(item),
|
|
254
|
+
label: label(item, labelled(evidence.map((view) => view.evidence))),
|
|
97
255
|
state: summary.state,
|
|
98
256
|
assignment
|
|
99
257
|
},
|
|
100
258
|
evidence,
|
|
101
259
|
analysis,
|
|
260
|
+
...catalog ? { catalog } : {},
|
|
261
|
+
...disposition ? { disposition } : {},
|
|
102
262
|
reviews,
|
|
263
|
+
...instruction ? { instruction } : {},
|
|
103
264
|
availability: "available",
|
|
104
265
|
...analysis?.configuration.stage === "interpret" ? { generation: analysis.result.output.proposals } : {}
|
|
105
266
|
};
|
|
106
267
|
},
|
|
107
268
|
upload: (files, requestId) => options.upload(files, requestId),
|
|
108
269
|
async preview(input) {
|
|
109
|
-
if (!Number.isSafeInteger(input.index) || input.index < 0 || typeof input.attemptId !== "string" || !input.attemptId || typeof input.requestId !== "string" || !input.requestId.trim()) throw new Error("Invalid preview");
|
|
270
|
+
if (!Number.isSafeInteger(input.index) || input.index < 0 || typeof input.attemptId !== "string" || !input.attemptId || typeof input.requestId !== "string" || !input.requestId.trim() || input.args !== void 0 && (!input.args || typeof input.args !== "object" || Array.isArray(input.args))) throw new Error("Invalid preview");
|
|
110
271
|
const analysis = await service.getCompletedAnalysis(input.itemId, input.attemptId);
|
|
111
272
|
const generation = analysis.result.output.proposals;
|
|
112
|
-
|
|
113
|
-
|
|
273
|
+
const suggestion = generation?.suggestions[input.index];
|
|
274
|
+
if (analysis.configuration.stage !== "interpret" || generation?.outcome === "provider_error" || !suggestion || !["ready_for_review", "needs_review"].includes(suggestion.disposition)) throw new Error("Preview unavailable");
|
|
114
275
|
const handler = generation.offered.find(({ handler: handler2 }) => handler2.id === suggestion.handlerId && handler2.version === suggestion.handlerVersion)?.handler;
|
|
115
276
|
if (!handler) throw new Error("Preview unavailable");
|
|
277
|
+
const args = input.args ? structuredClone(input.args) : suggestion.args;
|
|
116
278
|
const intentionKey = `ui:${input.index}`;
|
|
117
279
|
const actionId = handler.kind === "plan" ? void 0 : await service.createAction(input.itemId, intentionKey, {
|
|
118
280
|
origin: "generated-proposal",
|
|
@@ -129,7 +291,7 @@ function createIntakeReviewHost(service, options) {
|
|
|
129
291
|
expectedRevision = handler.kind === "plan" ? found.plan?.revision ?? found.stalePlan?.revision ?? 0 : found.review.revision;
|
|
130
292
|
if (binding?.attemptId === input.attemptId && binding.handlerId === suggestion.handlerId && binding.handlerVersion === suggestion.handlerVersion && binding.args) {
|
|
131
293
|
const { intakeBindingDigest } = await import("./execution.js");
|
|
132
|
-
if (intakeBindingDigest(binding.args) === intakeBindingDigest(
|
|
294
|
+
if (intakeBindingDigest(binding.args) === intakeBindingDigest(args)) return;
|
|
133
295
|
}
|
|
134
296
|
break;
|
|
135
297
|
}
|
|
@@ -137,6 +299,26 @@ function createIntakeReviewHost(service, options) {
|
|
|
137
299
|
if (cursor && cursors.has(cursor)) throw new Error("Repeated review cursor");
|
|
138
300
|
if (cursor) cursors.add(cursor);
|
|
139
301
|
} while (cursor);
|
|
302
|
+
if (input.args) {
|
|
303
|
+
const shared = {
|
|
304
|
+
itemId: input.itemId,
|
|
305
|
+
attemptId: input.attemptId,
|
|
306
|
+
expectedRevision,
|
|
307
|
+
requestId: input.requestId,
|
|
308
|
+
handlerId: suggestion.handlerId,
|
|
309
|
+
handlerVersion: suggestion.handlerVersion,
|
|
310
|
+
args
|
|
311
|
+
};
|
|
312
|
+
if (handler.kind === "plan") await service.previewPlan({
|
|
313
|
+
...shared,
|
|
314
|
+
planKey: intentionKey
|
|
315
|
+
});
|
|
316
|
+
else if (actionId) await service.previewProposal({
|
|
317
|
+
...shared,
|
|
318
|
+
actionId
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
140
322
|
await service.previewGeneratedProposals({
|
|
141
323
|
itemId: input.itemId,
|
|
142
324
|
attemptId: input.attemptId,
|
|
@@ -167,6 +349,23 @@ function createIntakeReviewHost(service, options) {
|
|
|
167
349
|
const result = await service.reviseLogicalSplit(input);
|
|
168
350
|
await options.onSplit?.(input.itemId, result.revision);
|
|
169
351
|
},
|
|
352
|
+
async startAction(input) {
|
|
353
|
+
await service.startReviewerAction(input);
|
|
354
|
+
},
|
|
355
|
+
async resolve(input) {
|
|
356
|
+
await service.resolveItem(input);
|
|
357
|
+
},
|
|
358
|
+
async instruct(input) {
|
|
359
|
+
let result;
|
|
360
|
+
try {
|
|
361
|
+
result = await service.recordReviewerInstruction(input);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (error instanceof ReviewerInstructionStaleError) return { stale: true };
|
|
364
|
+
if (error instanceof ReviewerInstructionInvalidError) return { invalid: true };
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
await options.onInstruct?.(input.itemId, result.revision);
|
|
368
|
+
},
|
|
170
369
|
...options.enableFeedback ? { feedback: (input) => service.recordFeedback(input) } : {}
|
|
171
370
|
};
|
|
172
371
|
}
|
|
@@ -190,6 +389,10 @@ function text(value) {
|
|
|
190
389
|
if (typeof value !== "string" || !value || value.length > 1024) throw new SourceInputError("invalid");
|
|
191
390
|
return value;
|
|
192
391
|
}
|
|
392
|
+
function withPresentedFilename(value) {
|
|
393
|
+
const filename = presentedFilename(value);
|
|
394
|
+
return filename ? { filename } : {};
|
|
395
|
+
}
|
|
193
396
|
function timestamp(value) {
|
|
194
397
|
const date = new Date(text(value));
|
|
195
398
|
if (!Number.isFinite(date.getTime())) throw new SourceInputError("invalid");
|
|
@@ -286,7 +489,10 @@ async function deliver(binding, deliveryKey, deliveredAt, parts) {
|
|
|
286
489
|
limits: binding.limits,
|
|
287
490
|
deliveryKey,
|
|
288
491
|
deliveredAt,
|
|
289
|
-
parts
|
|
492
|
+
parts: parts.map(({ filename, ...part }) => ({
|
|
493
|
+
...part,
|
|
494
|
+
...withPresentedFilename(filename)
|
|
495
|
+
}))
|
|
290
496
|
});
|
|
291
497
|
} catch (error) {
|
|
292
498
|
return safeFailure(error);
|
|
@@ -352,6 +558,7 @@ var EmailSourceAdapter = class {
|
|
|
352
558
|
parentPartId: "message",
|
|
353
559
|
mediaType: attachment.mediaType,
|
|
354
560
|
bytes: attachment.bytes,
|
|
561
|
+
filename: attachment.filename,
|
|
355
562
|
sourceReference: {
|
|
356
563
|
owner: "message-attachment",
|
|
357
564
|
id: `${delivery.locator}:${attachment.partId}`,
|
|
@@ -485,11 +692,14 @@ function createSourceDeliveryHandler(options) {
|
|
|
485
692
|
fields[name] = value;
|
|
486
693
|
} else {
|
|
487
694
|
if (name !== "file" || parts.length) throw new SourceInputError("invalid");
|
|
695
|
+
const presented = presentedFilename(value.name);
|
|
696
|
+
const original = presented === "blob" ? void 0 : presented;
|
|
488
697
|
parts.push({
|
|
489
698
|
partId: "file",
|
|
490
699
|
parentPartId: "capture",
|
|
491
700
|
mediaType: value.type,
|
|
492
|
-
bytes: await boundedBytes(value.stream(), binding.limits.maxBytes)
|
|
701
|
+
bytes: await boundedBytes(value.stream(), binding.limits.maxBytes),
|
|
702
|
+
...original ? { filename: original } : {}
|
|
493
703
|
});
|
|
494
704
|
}
|
|
495
705
|
if (parts.length !== 1 || text(fields.captureId) !== key || ![
|
|
@@ -604,6 +814,7 @@ var WatchFolderSourceAdapter = class {
|
|
|
604
814
|
partId: "original",
|
|
605
815
|
mediaType,
|
|
606
816
|
bytes,
|
|
817
|
+
filename: claim.filename,
|
|
607
818
|
sourceReference: {
|
|
608
819
|
owner: "watch-folder",
|
|
609
820
|
id: claim.filename,
|
|
@@ -728,6 +939,7 @@ var TABLES = [
|
|
|
728
939
|
"intake_review_decisions",
|
|
729
940
|
"intake_executions",
|
|
730
941
|
"intake_feedback",
|
|
942
|
+
"intake_dispositions",
|
|
731
943
|
"intake_dispatches",
|
|
732
944
|
"intake_deletions"
|
|
733
945
|
];
|
|
@@ -763,12 +975,153 @@ function bytesHash(value) {
|
|
|
763
975
|
function nonempty(value) {
|
|
764
976
|
if (typeof value !== "string" || !value.trim() || value.length > 512) throw new Error("Invalid intake identifier");
|
|
765
977
|
}
|
|
978
|
+
function filename(value) {
|
|
979
|
+
if (!isStorableFilename(value)) throw new Error("Invalid intake filename");
|
|
980
|
+
}
|
|
766
981
|
function integer(value, max = 2147483647) {
|
|
767
982
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0 || value > max) throw new Error("Invalid intake limit");
|
|
768
983
|
}
|
|
984
|
+
var ITEM_PAGE_DEFAULT = 20;
|
|
985
|
+
var ITEM_PAGE_CEILING = 100;
|
|
986
|
+
var ITEM_SCAN_CEILING = 1e3;
|
|
987
|
+
var ITEM_ID_FILTER_CEILING = 1e3;
|
|
988
|
+
function isIntakeId(value) {
|
|
989
|
+
return typeof value === "string" && uuid.test(value);
|
|
990
|
+
}
|
|
991
|
+
var INBOX_STATES = [
|
|
992
|
+
"resolved",
|
|
993
|
+
"processing",
|
|
994
|
+
"unresolved",
|
|
995
|
+
"waiting",
|
|
996
|
+
"failed",
|
|
997
|
+
"deferred",
|
|
998
|
+
"partially_completed",
|
|
999
|
+
"completed"
|
|
1000
|
+
];
|
|
1001
|
+
function actionCount(where = "") {
|
|
1002
|
+
return `(SELECT COUNT(*) FROM intake_actions a WHERE a.tenant_id = i.tenant_id AND a.confidential_scope_id = i.confidential_scope_id AND a.item_id = i.id${where ? ` AND ${where}` : ""})`;
|
|
1003
|
+
}
|
|
1004
|
+
var dispositionWhere = "d.tenant_id=i.tenant_id AND d.confidential_scope_id=i.confidential_scope_id AND d.item_id=i.id";
|
|
1005
|
+
var INBOX_STATE_SQL = `CASE WHEN EXISTS (SELECT 1 FROM intake_dispositions d WHERE ${dispositionWhere}) AND ${actionCount("a.state = 'succeeded'")} = 0 THEN 'resolved' WHEN i.processing_state IN ('queued','running') THEN 'processing' WHEN ${actionCount()} = 0 THEN CASE WHEN i.processing_state = 'needs_attention' THEN 'failed' ELSE 'unresolved' END WHEN ${actionCount("a.state <> 'succeeded'")} = 0 THEN 'completed' WHEN ${actionCount("a.state = 'succeeded'")} > 0 THEN 'partially_completed' WHEN ${actionCount("a.state = 'deferred'")} > 0 THEN 'deferred' WHEN ${actionCount("a.state IN ('failed','outcome_unknown')")} > 0 THEN 'failed' ELSE 'waiting' END`;
|
|
1006
|
+
var RECEIPT_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
1007
|
+
function receiptTime(row) {
|
|
1008
|
+
const value = row.created_at;
|
|
1009
|
+
if (typeof value === "string") return RECEIPT_TIME.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value ? value : void 0;
|
|
1010
|
+
if (value instanceof Date && Number.isFinite(value.getTime()) && (row.receipt_ms === true || Number(row.receipt_ms) === 1)) return value.toISOString();
|
|
1011
|
+
}
|
|
1012
|
+
var IntakeScanLimitError = class extends Error {
|
|
1013
|
+
constructor() {
|
|
1014
|
+
super("Intake scan limit");
|
|
1015
|
+
this.name = "IntakeScanLimitError";
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
var SEALED_CURSOR_BYTES = 1024;
|
|
1019
|
+
var SCAN_KEY_CHARS = 64;
|
|
1020
|
+
function samePosition(a, b) {
|
|
1021
|
+
return a.key === b.key && a.id === b.id;
|
|
1022
|
+
}
|
|
1023
|
+
function encodeItemCursor(receivedAt, id) {
|
|
1024
|
+
return Buffer.from(JSON.stringify([receivedAt, id])).toString("base64url");
|
|
1025
|
+
}
|
|
1026
|
+
function decodeItemCursor(cursor) {
|
|
1027
|
+
let parsed;
|
|
1028
|
+
try {
|
|
1029
|
+
parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
1030
|
+
} catch {
|
|
1031
|
+
throw new Error("Invalid item page");
|
|
1032
|
+
}
|
|
1033
|
+
if (!Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== "string" || !Number.isFinite(Date.parse(parsed[0])) || new Date(parsed[0]).toISOString() !== parsed[0] || typeof parsed[1] !== "string" || !uuid.test(parsed[1])) throw new Error("Invalid item page");
|
|
1034
|
+
return {
|
|
1035
|
+
receivedAt: parsed[0],
|
|
1036
|
+
id: parsed[1]
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
769
1039
|
function persistedDate(value) {
|
|
770
1040
|
return value instanceof Date ? value : new Date(String(value));
|
|
771
1041
|
}
|
|
1042
|
+
var inlineMedia = /* @__PURE__ */ new Set(["text/plain", "application/json"]);
|
|
1043
|
+
var REDACTED = "redacted";
|
|
1044
|
+
var REDACTED_DIGESTS = [
|
|
1045
|
+
["intake_analyses", "input_digest"],
|
|
1046
|
+
["intake_analysis_attempts", "output_digest"],
|
|
1047
|
+
["intake_plans", "digest"],
|
|
1048
|
+
["intake_proposals", "binding_hash"],
|
|
1049
|
+
["intake_review_decisions", "binding_hash"],
|
|
1050
|
+
["intake_executions", "binding_hash"]
|
|
1051
|
+
];
|
|
1052
|
+
function mediaKind(mediaType) {
|
|
1053
|
+
return mediaType.split(";")[0].trim().toLowerCase();
|
|
1054
|
+
}
|
|
1055
|
+
function ownerKey(owner) {
|
|
1056
|
+
nonempty(owner.kind);
|
|
1057
|
+
nonempty(owner.id);
|
|
1058
|
+
if (owner.kind.includes(":") || !storableText(owner.kind) || !storableText(owner.id)) throw new Error("Invalid intake owner");
|
|
1059
|
+
return `${owner.kind}:${owner.id}`;
|
|
1060
|
+
}
|
|
1061
|
+
function storableText(value) {
|
|
1062
|
+
if (value.includes("\0")) return false;
|
|
1063
|
+
for (let i = 0; i < value.length; i++) {
|
|
1064
|
+
const code = value.charCodeAt(i);
|
|
1065
|
+
if (code >= 55296 && code <= 56319) {
|
|
1066
|
+
const next = value.charCodeAt(i + 1);
|
|
1067
|
+
if (!(next >= 56320 && next <= 57343)) return false;
|
|
1068
|
+
i++;
|
|
1069
|
+
} else if (code >= 56320 && code <= 57343) return false;
|
|
1070
|
+
}
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
function receivedPart(part) {
|
|
1074
|
+
nonempty(part.partId);
|
|
1075
|
+
if (!("reference" in part)) {
|
|
1076
|
+
nonempty(part.mediaType);
|
|
1077
|
+
if (!(part.bytes instanceof Uint8Array)) throw new Error("Invalid part");
|
|
1078
|
+
const bytes2 = Buffer.from(part.bytes);
|
|
1079
|
+
const hash = bytesHash(bytes2);
|
|
1080
|
+
if (part.expectedHash && part.expectedHash !== hash) throw new Error("Invalid hash");
|
|
1081
|
+
if (part.filename !== void 0) filename(part.filename);
|
|
1082
|
+
return {
|
|
1083
|
+
...part,
|
|
1084
|
+
bytes: bytes2,
|
|
1085
|
+
hash,
|
|
1086
|
+
retain: "copy"
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
const reference = object(part.reference);
|
|
1090
|
+
nonempty(reference.owner);
|
|
1091
|
+
nonempty(reference.id);
|
|
1092
|
+
if (reference.version !== void 0) nonempty(reference.version);
|
|
1093
|
+
for (const value2 of [
|
|
1094
|
+
reference.owner,
|
|
1095
|
+
reference.id,
|
|
1096
|
+
reference.version
|
|
1097
|
+
]) if (typeof value2 === "string" && !storableText(value2)) throw new Error("Invalid reference");
|
|
1098
|
+
const snapshot = object(part.snapshot);
|
|
1099
|
+
let mediaType;
|
|
1100
|
+
let value;
|
|
1101
|
+
if ("text" in snapshot && !("structured" in snapshot)) {
|
|
1102
|
+
if (typeof snapshot.text !== "string" || !storableText(snapshot.text)) throw new Error("Invalid snapshot");
|
|
1103
|
+
mediaType = "text/plain";
|
|
1104
|
+
value = snapshot.text;
|
|
1105
|
+
} else if ("structured" in snapshot && !("text" in snapshot)) {
|
|
1106
|
+
mediaType = "application/json";
|
|
1107
|
+
value = canonical(object(snapshot.structured));
|
|
1108
|
+
} else throw new Error("Invalid snapshot");
|
|
1109
|
+
const bytes = Buffer.from(value, "utf8");
|
|
1110
|
+
return {
|
|
1111
|
+
partId: part.partId,
|
|
1112
|
+
...part.parentPartId === void 0 ? {} : { parentPartId: part.parentPartId },
|
|
1113
|
+
mediaType,
|
|
1114
|
+
bytes,
|
|
1115
|
+
hash: bytesHash(bytes),
|
|
1116
|
+
sourceReference: {
|
|
1117
|
+
owner: reference.owner,
|
|
1118
|
+
id: reference.id,
|
|
1119
|
+
version: reference.version ?? ""
|
|
1120
|
+
},
|
|
1121
|
+
retain: "reference",
|
|
1122
|
+
snapshot: value
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
772
1125
|
function date(value) {
|
|
773
1126
|
if (!(value instanceof Date) || !Number.isFinite(value.getTime())) throw new Error("Invalid intake date");
|
|
774
1127
|
return value.toISOString();
|
|
@@ -783,12 +1136,18 @@ var IngestionService = class {
|
|
|
783
1136
|
}
|
|
784
1137
|
this.db = options.db;
|
|
785
1138
|
this.scope = Object.freeze({ ...options.scope });
|
|
1139
|
+
if (options.cursorKey !== void 0) {
|
|
1140
|
+
const secret = typeof options.cursorKey === "string" ? Buffer.from(options.cursorKey, "utf8") : Buffer.from(options.cursorKey);
|
|
1141
|
+
if (secret.length < 32) throw new Error("Cursor key must be at least 32 bytes");
|
|
1142
|
+
this.cursorKey = createHash("sha256").update("smrt-ingestion:list-cursor:v1").update(secret).digest();
|
|
1143
|
+
}
|
|
786
1144
|
if (!uuid.test(this.scope.tenantId)) throw new Error("Required tenant UUID");
|
|
787
1145
|
nonempty(this.scope.actorId);
|
|
788
1146
|
nonempty(this.scope.confidentialScopeId);
|
|
789
1147
|
const configured = this.db;
|
|
790
1148
|
const engine = detectEngine(this.db.url || configured.config?.url || "", configured.type || configured.config?.type);
|
|
791
1149
|
if (!["sqlite", "postgres"].includes(engine) || !this.db.transaction) throw new Error("Ingestion requires transactional SQLite or PostgreSQL");
|
|
1150
|
+
this.engine = engine;
|
|
792
1151
|
nonempty(options.jobTarget.objectType);
|
|
793
1152
|
nonempty(options.jobTarget.method);
|
|
794
1153
|
this.dispatchTarget = Object.freeze({
|
|
@@ -800,6 +1159,8 @@ var IngestionService = class {
|
|
|
800
1159
|
options;
|
|
801
1160
|
db;
|
|
802
1161
|
scope;
|
|
1162
|
+
engine;
|
|
1163
|
+
cursorKey;
|
|
803
1164
|
dispatchTarget;
|
|
804
1165
|
async execution() {
|
|
805
1166
|
if (!this.options.execution) throw new Error("Intake execution is not configured");
|
|
@@ -824,9 +1185,28 @@ var IngestionService = class {
|
|
|
824
1185
|
async getAction(actionId) {
|
|
825
1186
|
return (await this.execution()).getAction(actionId);
|
|
826
1187
|
}
|
|
1188
|
+
/** Own-submission review surface; see execution for its authority gate. */
|
|
1189
|
+
async listOwnProposals(input = {}) {
|
|
1190
|
+
return (await this.execution()).listOwnProposals(input);
|
|
1191
|
+
}
|
|
1192
|
+
async submitOwnDecision(input) {
|
|
1193
|
+
return (await this.execution()).submitOwnDecision(input);
|
|
1194
|
+
}
|
|
827
1195
|
async authorizeAutomatic(actionId) {
|
|
828
1196
|
return (await this.execution()).authorizeAutomatic(actionId);
|
|
829
1197
|
}
|
|
1198
|
+
/** Reviewer-chosen handler for any item; same preview/approval path, never automatic. */
|
|
1199
|
+
async startReviewerAction(input) {
|
|
1200
|
+
return (await this.execution()).startAction(input);
|
|
1201
|
+
}
|
|
1202
|
+
/** Terminal "handled, no action" disposition; distinct from rejecting a proposal. */
|
|
1203
|
+
async resolveItem(input) {
|
|
1204
|
+
return (await this.execution()).resolveItem(input);
|
|
1205
|
+
}
|
|
1206
|
+
/** Reviewer and reason of a resolved item; requires reviewer authority. */
|
|
1207
|
+
async getItemDisposition(itemId) {
|
|
1208
|
+
return (await this.execution()).getItemDisposition(itemId);
|
|
1209
|
+
}
|
|
830
1210
|
async previewPlan(input) {
|
|
831
1211
|
return (await this.execution()).previewPlan(input);
|
|
832
1212
|
}
|
|
@@ -834,18 +1214,19 @@ var IngestionService = class {
|
|
|
834
1214
|
return (await this.execution()).applyPlan(planId);
|
|
835
1215
|
}
|
|
836
1216
|
async proposals() {
|
|
837
|
-
if (!this.options.proposals) throw new
|
|
1217
|
+
if (!this.options.proposals) throw new ProposalsNotConfiguredError();
|
|
838
1218
|
const { IngestionProposalService } = await import("./proposals.js");
|
|
839
1219
|
return new IngestionProposalService(this, this.options, this.options.proposals);
|
|
840
1220
|
}
|
|
841
1221
|
async proposalReader() {
|
|
842
1222
|
const config = this.options.proposals ?? this.options.proposalRead;
|
|
843
|
-
if (!config) throw new
|
|
1223
|
+
if (!config) throw new ProposalsNotConfiguredError();
|
|
844
1224
|
const { IngestionProposalService } = await import("./proposals.js");
|
|
845
1225
|
return new IngestionProposalService(this, this.options, config);
|
|
846
1226
|
}
|
|
1227
|
+
/** Currently offered reviewer catalog; needs no generation credentials. */
|
|
847
1228
|
async listHandlers(itemId) {
|
|
848
|
-
return (await this.
|
|
1229
|
+
return (await this.proposalReader()).listHandlers(itemId);
|
|
849
1230
|
}
|
|
850
1231
|
async findCandidates(input) {
|
|
851
1232
|
return (await this.proposals()).findCandidates(input);
|
|
@@ -860,7 +1241,7 @@ var IngestionService = class {
|
|
|
860
1241
|
return (await this.proposalReader()).preview(input);
|
|
861
1242
|
}
|
|
862
1243
|
async feedback() {
|
|
863
|
-
const { IntakeFeedbackService } = await import("./chunks/feedback-
|
|
1244
|
+
const { IntakeFeedbackService } = await import("./chunks/feedback-D3UTF8fm.js").then((n) => n.r);
|
|
864
1245
|
return new IntakeFeedbackService(this.options);
|
|
865
1246
|
}
|
|
866
1247
|
async recordFeedback(input) {
|
|
@@ -913,6 +1294,10 @@ var IngestionService = class {
|
|
|
913
1294
|
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
|
|
914
1295
|
}
|
|
915
1296
|
}
|
|
1297
|
+
/** Refuse under the caller's item lock when a terminal disposition exists. */
|
|
1298
|
+
async assertUnresolved(db, itemId) {
|
|
1299
|
+
if ((await this.rows(db, "intake_dispositions", "item_id=?", [itemId])).length) throw new Error("Item resolved");
|
|
1300
|
+
}
|
|
916
1301
|
async lock(db, itemId) {
|
|
917
1302
|
if (!(await db.query("UPDATE intake_items SET analysis_revision = analysis_revision WHERE tenant_id = ? AND confidential_scope_id = ? AND id = ? RETURNING id", this.scope.tenantId, this.scope.confidentialScopeId, itemId)).rows.length) throw new Error("Intake unavailable");
|
|
918
1303
|
}
|
|
@@ -940,6 +1325,8 @@ var IngestionService = class {
|
|
|
940
1325
|
await this.authorize("receive", { capturedCeiling: input.capturedCeiling });
|
|
941
1326
|
const now = this.now();
|
|
942
1327
|
let parts;
|
|
1328
|
+
let owner = null;
|
|
1329
|
+
const inline = input.dispatch === "inline";
|
|
943
1330
|
try {
|
|
944
1331
|
for (const value of [
|
|
945
1332
|
input.sourceId,
|
|
@@ -947,6 +1334,16 @@ var IngestionService = class {
|
|
|
947
1334
|
input.deliveryKey,
|
|
948
1335
|
input.retention.version
|
|
949
1336
|
]) nonempty(value);
|
|
1337
|
+
if (input.dispatch !== void 0 && !["job", "inline"].includes(input.dispatch)) throw new Error("Invalid dispatch mode");
|
|
1338
|
+
const retention = input.retention;
|
|
1339
|
+
if (retention.kind !== void 0 && !["time", "owner"].includes(retention.kind)) throw new Error("Invalid retention mode");
|
|
1340
|
+
if (retention.kind === "owner") {
|
|
1341
|
+
ownerKey(retention.owner);
|
|
1342
|
+
owner = {
|
|
1343
|
+
kind: retention.owner.kind,
|
|
1344
|
+
id: retention.owner.id
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
950
1347
|
for (const value of Object.values(input.limits)) integer(value);
|
|
951
1348
|
date(input.deliveredAt);
|
|
952
1349
|
date(input.retention.expiresAt);
|
|
@@ -963,23 +1360,17 @@ var IngestionService = class {
|
|
|
963
1360
|
const ids = /* @__PURE__ */ new Set();
|
|
964
1361
|
let total = 0;
|
|
965
1362
|
parts = input.parts.map((part) => {
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
const hash = bytesHash(part.bytes);
|
|
972
|
-
if (part.expectedHash && part.expectedHash !== hash) throw new Error("Invalid hash");
|
|
973
|
-
return {
|
|
974
|
-
...part,
|
|
975
|
-
bytes: Buffer.from(part.bytes),
|
|
976
|
-
hash
|
|
977
|
-
};
|
|
1363
|
+
const received = receivedPart(part);
|
|
1364
|
+
if (ids.has(received.partId)) throw new Error("Invalid part");
|
|
1365
|
+
ids.add(received.partId);
|
|
1366
|
+
total += received.bytes.byteLength;
|
|
1367
|
+
return received;
|
|
978
1368
|
});
|
|
979
1369
|
if (total > input.limits.maxBytes) return {
|
|
980
1370
|
kind: "rejected",
|
|
981
1371
|
category: "limit"
|
|
982
1372
|
};
|
|
1373
|
+
if (inline && parts.some((p) => !inlineMedia.has(mediaKind(p.mediaType)))) throw new Error("Inline receipt requires text or structured parts");
|
|
983
1374
|
for (const part of parts) if (part.parentPartId && (!ids.has(part.parentPartId) || part.parentPartId === part.partId)) throw new Error("Invalid parent");
|
|
984
1375
|
for (const part of parts) {
|
|
985
1376
|
const seen = /* @__PURE__ */ new Set([part.partId]);
|
|
@@ -1002,12 +1393,15 @@ var IngestionService = class {
|
|
|
1002
1393
|
mediaType: p.mediaType,
|
|
1003
1394
|
hash: p.hash,
|
|
1004
1395
|
length: p.bytes.byteLength,
|
|
1005
|
-
sourceReference: p.sourceReference ?? null
|
|
1396
|
+
sourceReference: p.sourceReference ?? null,
|
|
1397
|
+
...p.retain === "reference" ? { retain: "reference" } : {}
|
|
1006
1398
|
})).sort((a, b) => a.partId.localeCompare(b.partId)));
|
|
1007
1399
|
const scopeDigest = digest({
|
|
1008
1400
|
scope: this.scope,
|
|
1009
1401
|
ceiling: input.capturedCeiling,
|
|
1010
|
-
sourceVersion: input.sourceVersion
|
|
1402
|
+
sourceVersion: input.sourceVersion,
|
|
1403
|
+
...inline ? { dispatch: "inline" } : {},
|
|
1404
|
+
...owner ? { owner } : {}
|
|
1011
1405
|
});
|
|
1012
1406
|
const reservation = await this.tx(async (db) => {
|
|
1013
1407
|
await this.authorize("receive", { capturedCeiling: input.capturedCeiling }, db);
|
|
@@ -1029,12 +1423,16 @@ var IngestionService = class {
|
|
|
1029
1423
|
replay_until: date(input.retention.replayUntil),
|
|
1030
1424
|
cancelled: false,
|
|
1031
1425
|
trace_id: input.traceId ?? randomUUID(),
|
|
1426
|
+
submitter_id: this.scope.actorId,
|
|
1427
|
+
owner_key: owner ? ownerKey(owner) : "",
|
|
1032
1428
|
data: canonical({
|
|
1033
1429
|
capturedCeiling: input.capturedCeiling,
|
|
1034
1430
|
limits: input.limits,
|
|
1035
1431
|
retentionVersion: input.retention.version,
|
|
1036
1432
|
deliveredAt: date(input.deliveredAt),
|
|
1037
|
-
dispatchTarget: this.dispatchTarget
|
|
1433
|
+
dispatchTarget: this.dispatchTarget,
|
|
1434
|
+
...inline ? { dispatch: "inline" } : {},
|
|
1435
|
+
...owner ? { retentionOwner: owner } : {}
|
|
1038
1436
|
})
|
|
1039
1437
|
}, "tenant_id,source_id,delivery_key");
|
|
1040
1438
|
const item2 = (await db.query("SELECT * FROM intake_items WHERE tenant_id = ? AND source_id = ? AND delivery_key = ?", this.scope.tenantId, input.sourceId, input.deliveryKey)).rows[0];
|
|
@@ -1051,13 +1449,20 @@ var IngestionService = class {
|
|
|
1051
1449
|
item_id: item2.id,
|
|
1052
1450
|
part_id: part.partId,
|
|
1053
1451
|
parent_evidence_id: part.parentPartId ? evidenceIds.get(part.parentPartId) : null,
|
|
1054
|
-
asset_id: randomUUID(),
|
|
1055
|
-
state: "intent",
|
|
1452
|
+
asset_id: part.retain === "reference" ? null : randomUUID(),
|
|
1453
|
+
state: part.retain === "reference" ? "durable" : "intent",
|
|
1056
1454
|
content_hash: part.hash,
|
|
1057
1455
|
byte_length: part.bytes.byteLength,
|
|
1058
1456
|
media_type: part.mediaType,
|
|
1059
1457
|
source_uri: "",
|
|
1060
|
-
data: canonical({
|
|
1458
|
+
data: canonical({
|
|
1459
|
+
sourceReference: part.sourceReference ?? null,
|
|
1460
|
+
...part.filename === void 0 ? {} : { filename: part.filename },
|
|
1461
|
+
...part.retain === "reference" ? {
|
|
1462
|
+
retain: "reference",
|
|
1463
|
+
snapshot: part.snapshot
|
|
1464
|
+
} : {}
|
|
1465
|
+
})
|
|
1061
1466
|
});
|
|
1062
1467
|
}
|
|
1063
1468
|
return {
|
|
@@ -1074,8 +1479,12 @@ var IngestionService = class {
|
|
|
1074
1479
|
const itemId = String(item.id);
|
|
1075
1480
|
await this.point("reserved");
|
|
1076
1481
|
if (item.receipt_state === "ready") {
|
|
1482
|
+
if (await this.revokedBeforeAck(itemId)) return {
|
|
1483
|
+
kind: "rejected",
|
|
1484
|
+
category: "expired"
|
|
1485
|
+
};
|
|
1077
1486
|
try {
|
|
1078
|
-
await this.repairDispatches();
|
|
1487
|
+
if (!inline) await this.repairDispatches();
|
|
1079
1488
|
} catch {
|
|
1080
1489
|
return {
|
|
1081
1490
|
kind: "retry",
|
|
@@ -1089,12 +1498,15 @@ var IngestionService = class {
|
|
|
1089
1498
|
};
|
|
1090
1499
|
}
|
|
1091
1500
|
const token = randomUUID();
|
|
1092
|
-
if (!(await this.tx((db) => db.query(`UPDATE intake_items SET receipt_state = 'preserving', fence = fence + 1, lease_token = ?, lease_until = ? WHERE tenant_id = ? AND confidential_scope_id = ? AND id = ? AND visibility = 'active' AND receipt_state <> 'ready' AND (lease_until IS NULL OR lease_until <= ?) RETURNING fence`, token, new Date(now.getTime() + input.limits.leaseMs).toISOString(), this.scope.tenantId, this.scope.confidentialScopeId, itemId, now.toISOString()))).rows.length) return {
|
|
1501
|
+
if (!(await this.tx((db) => db.query(`UPDATE intake_items SET receipt_state = 'preserving', fence = fence + 1, lease_token = ?, lease_until = ? WHERE tenant_id = ? AND confidential_scope_id = ? AND id = ? AND visibility = 'active' AND receipt_state <> 'ready' AND (lease_until IS NULL OR lease_until <= ?) RETURNING fence`, token, new Date(now.getTime() + input.limits.leaseMs).toISOString(), this.scope.tenantId, this.scope.confidentialScopeId, itemId, now.toISOString()))).rows.length) return await this.revokedBeforeAck(itemId) ? {
|
|
1502
|
+
kind: "rejected",
|
|
1503
|
+
category: "expired"
|
|
1504
|
+
} : {
|
|
1093
1505
|
kind: "retry",
|
|
1094
1506
|
category: "busy"
|
|
1095
1507
|
};
|
|
1096
1508
|
try {
|
|
1097
|
-
for (const part of parts) await this.preserve(itemId, part, token);
|
|
1509
|
+
for (const part of parts) if (part.retain === "copy") await this.preserve(itemId, part, token);
|
|
1098
1510
|
await this.point("evidence");
|
|
1099
1511
|
await this.tx(async (db) => {
|
|
1100
1512
|
await this.lock(db, itemId);
|
|
@@ -1106,15 +1518,19 @@ var IngestionService = class {
|
|
|
1106
1518
|
item_id: itemId,
|
|
1107
1519
|
stage: "analyze",
|
|
1108
1520
|
revision: 1,
|
|
1109
|
-
state: "pending",
|
|
1521
|
+
state: inline ? "completed" : "pending",
|
|
1110
1522
|
job_id: "",
|
|
1111
1523
|
deliveries: 0,
|
|
1112
1524
|
data: canonical({ dispatchTarget: this.dispatchTarget })
|
|
1113
1525
|
}, "tenant_id,item_id,stage,revision");
|
|
1114
1526
|
});
|
|
1115
1527
|
await this.point("ready");
|
|
1116
|
-
await this.repairDispatches();
|
|
1528
|
+
if (!inline) await this.repairDispatches();
|
|
1117
1529
|
await this.point("ack");
|
|
1530
|
+
if (await this.revokedBeforeAck(itemId)) return {
|
|
1531
|
+
kind: "rejected",
|
|
1532
|
+
category: "expired"
|
|
1533
|
+
};
|
|
1118
1534
|
return {
|
|
1119
1535
|
kind: reservation.created ? "accepted" : "duplicate",
|
|
1120
1536
|
itemId,
|
|
@@ -1122,12 +1538,20 @@ var IngestionService = class {
|
|
|
1122
1538
|
};
|
|
1123
1539
|
} catch {
|
|
1124
1540
|
await this.tx((db) => db.query(`UPDATE intake_items SET receipt_state='needs_attention',lease_token='',lease_until=NULL WHERE tenant_id=? AND confidential_scope_id=? AND id=? AND lease_token=? AND receipt_state<>'ready'`, this.scope.tenantId, this.scope.confidentialScopeId, itemId, token));
|
|
1541
|
+
if (await this.revokedBeforeAck(itemId)) return {
|
|
1542
|
+
kind: "rejected",
|
|
1543
|
+
category: "expired"
|
|
1544
|
+
};
|
|
1125
1545
|
return {
|
|
1126
1546
|
kind: "retry",
|
|
1127
1547
|
category: "unavailable"
|
|
1128
1548
|
};
|
|
1129
1549
|
}
|
|
1130
1550
|
}
|
|
1551
|
+
async revokedBeforeAck(itemId) {
|
|
1552
|
+
const [row] = await this.rows(this.db, "intake_items", "id=?", [itemId]);
|
|
1553
|
+
return !row || row.visibility !== "active";
|
|
1554
|
+
}
|
|
1131
1555
|
async preserve(itemId, part, token) {
|
|
1132
1556
|
const [evidence] = await this.rows(this.db, "intake_evidence", "item_id=? AND part_id=?", [itemId, part.partId]);
|
|
1133
1557
|
if (!evidence) throw new Error("Missing storage intent");
|
|
@@ -1385,66 +1809,391 @@ var IngestionService = class {
|
|
|
1385
1809
|
}
|
|
1386
1810
|
return repaired;
|
|
1387
1811
|
}
|
|
1388
|
-
/**
|
|
1812
|
+
/**
|
|
1813
|
+
* One authorized inbox page, newest receipt first with the id as tiebreak.
|
|
1814
|
+
* Tenant, confidential scope, retention and the id restriction run in the
|
|
1815
|
+
* query; rows are authorized before the `state` filter is applied to the
|
|
1816
|
+
* fetched batch, so every filter is bounded by the scan limit (default
|
|
1817
|
+
* ITEM_SCAN_CEILING). Authority is rechecked at two levels: the host
|
|
1818
|
+
* `authorize('read')` once per distinct captured ceiling, and the execution
|
|
1819
|
+
* read gate once per distinct (item, handler) bound by a scanned row that
|
|
1820
|
+
* passed the first. Without a `cursorKey` every cursor names a row the
|
|
1821
|
+
* actor can read; with one, all cursors are sealed and a budget-stopped
|
|
1822
|
+
* page may resume past rows the actor cannot read. `scanned` is for trusted
|
|
1823
|
+
* host accounting and must not be sent to clients.
|
|
1824
|
+
*/
|
|
1825
|
+
async listItemsPage(input = {}) {
|
|
1826
|
+
const limit = input.limit ?? ITEM_PAGE_DEFAULT;
|
|
1827
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || input.state !== void 0 && !INBOX_STATES.includes(input.state) || input.itemIds !== void 0 && (!Array.isArray(input.itemIds) || input.itemIds.length > 1e3 || !input.itemIds.every(isIntakeId)) || input.scanLimit !== void 0 && (!Number.isSafeInteger(input.scanLimit) || input.scanLimit < 1 || input.scanLimit > 1e3)) throw new Error("Invalid item page");
|
|
1828
|
+
if (input.itemIds && !input.itemIds.length) return {
|
|
1829
|
+
items: [],
|
|
1830
|
+
scanned: 0
|
|
1831
|
+
};
|
|
1832
|
+
const start = input.cursor ? this.decodeCursor(input.cursor) : void 0;
|
|
1833
|
+
const scan = await this.scanItems(input, limit, input.scanLimit ?? 1e3, start);
|
|
1834
|
+
const items = scan.items.map(({ item, state, position: position2 }) => ({
|
|
1835
|
+
item,
|
|
1836
|
+
state,
|
|
1837
|
+
cursor: this.encodeCursor(position2)
|
|
1838
|
+
}));
|
|
1839
|
+
if (scan.done) return {
|
|
1840
|
+
items,
|
|
1841
|
+
scanned: scan.scanned
|
|
1842
|
+
};
|
|
1843
|
+
const last = scan.items.at(-1)?.position;
|
|
1844
|
+
if (scan.full && last) return {
|
|
1845
|
+
items,
|
|
1846
|
+
scanned: scan.scanned,
|
|
1847
|
+
nextCursor: this.encodeCursor(last)
|
|
1848
|
+
};
|
|
1849
|
+
const position = this.cursorKey ? scan.scanFrom : scan.lastReadable;
|
|
1850
|
+
if (!position || start && samePosition(position, start)) throw new IntakeScanLimitError();
|
|
1851
|
+
return {
|
|
1852
|
+
items,
|
|
1853
|
+
scanned: scan.scanned,
|
|
1854
|
+
nextCursor: this.encodeCursor(position)
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* The bounded page scan behind `listItemsPage` and `listItems`. It returns
|
|
1859
|
+
* the rows to list with their positions, the exact scan position reached
|
|
1860
|
+
* (`scanFrom`, which may be a row the actor cannot read and must never
|
|
1861
|
+
* leave the service unsealed), and the last row the actor can read
|
|
1862
|
+
* (`lastReadable`, which may not match the `state` filter).
|
|
1863
|
+
*/
|
|
1864
|
+
async scanItems(input, limit, scanCeiling, start) {
|
|
1865
|
+
let scanFrom = start;
|
|
1866
|
+
let lastReadable;
|
|
1867
|
+
const now = this.now().toISOString();
|
|
1868
|
+
const allowed = this.ceilingGate();
|
|
1869
|
+
const handlers = this.handlerGate();
|
|
1870
|
+
const items = [];
|
|
1871
|
+
let examined = 0;
|
|
1872
|
+
const sqlite = this.engine === "sqlite";
|
|
1873
|
+
const pinned = sqlite && !input.itemIds ? " INDEXED BY intake_items_received_idx" : "";
|
|
1874
|
+
const textOnly = sqlite ? ` AND typeof(i.created_at) = 'text' AND length(i.created_at) <= ${SCAN_KEY_CHARS} AND length(i.id) <= ${SCAN_KEY_CHARS} AND instr(i.created_at, char(0)) = 0 AND instr(i.id, char(0)) = 0` : "";
|
|
1875
|
+
const receipt = sqlite ? "i.created_at AS receipt_raw, 1 AS receipt_ms" : "i.created_at::text AS receipt_raw, (i.created_at = date_trunc('milliseconds', i.created_at)) AS receipt_ms";
|
|
1876
|
+
for (;;) {
|
|
1877
|
+
const params = [
|
|
1878
|
+
this.scope.tenantId,
|
|
1879
|
+
this.scope.confidentialScopeId,
|
|
1880
|
+
now
|
|
1881
|
+
];
|
|
1882
|
+
let where = textOnly;
|
|
1883
|
+
if (scanFrom) {
|
|
1884
|
+
where += " AND (i.created_at < ? OR (i.created_at = ? AND i.id < ?))";
|
|
1885
|
+
params.push(scanFrom.key, scanFrom.key, scanFrom.id);
|
|
1886
|
+
}
|
|
1887
|
+
if (input.itemIds) {
|
|
1888
|
+
where += ` AND i.id IN (${input.itemIds.map(() => "?").join(",")})`;
|
|
1889
|
+
params.push(...input.itemIds);
|
|
1890
|
+
}
|
|
1891
|
+
const wanted = limit - items.length;
|
|
1892
|
+
const batch = Math.min(scanCeiling - examined, Math.max(wanted, Math.min(100, examined)));
|
|
1893
|
+
const rows = (await this.db.query(`SELECT i.*, (SELECT first_d.created_at FROM intake_dispositions first_d WHERE first_d.tenant_id=i.tenant_id AND first_d.confidential_scope_id=i.confidential_scope_id AND first_d.item_id=i.id AND CAST(first_d.id AS TEXT)=(SELECT MIN(CAST(d.id AS TEXT)) FROM intake_dispositions d WHERE ${dispositionWhere})) AS disposition_at, ${INBOX_STATE_SQL} AS inbox_state, ${actionCount()} AS action_count, ${receipt} FROM intake_items i${pinned} WHERE i.tenant_id = ? AND i.confidential_scope_id = ? AND i.visibility = 'active' AND i.expires_at > ?${where} ORDER BY i.created_at DESC, i.id DESC LIMIT ?`, ...params, batch + 1)).rows;
|
|
1894
|
+
const more = rows.length > batch;
|
|
1895
|
+
const candidates = rows.slice(0, batch);
|
|
1896
|
+
const passed = /* @__PURE__ */ new Set();
|
|
1897
|
+
for (const row of candidates) {
|
|
1898
|
+
let data;
|
|
1899
|
+
try {
|
|
1900
|
+
data = object(row.data);
|
|
1901
|
+
} catch {
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
if (await allowed(data)) passed.add(row);
|
|
1905
|
+
}
|
|
1906
|
+
await handlers.resolve(candidates.filter((row) => passed.has(row) && Number(row.action_count) > 0));
|
|
1907
|
+
let consumed = 0;
|
|
1908
|
+
for (const row of candidates) {
|
|
1909
|
+
consumed++;
|
|
1910
|
+
examined++;
|
|
1911
|
+
const id = String(row.id);
|
|
1912
|
+
const receivedAt = receiptTime(row);
|
|
1913
|
+
if (receivedAt !== void 0) scanFrom = {
|
|
1914
|
+
key: receivedAt,
|
|
1915
|
+
id
|
|
1916
|
+
};
|
|
1917
|
+
else if (typeof row.receipt_raw === "string") scanFrom = {
|
|
1918
|
+
key: row.receipt_raw,
|
|
1919
|
+
id
|
|
1920
|
+
};
|
|
1921
|
+
if (receivedAt === void 0 || !isIntakeId(id) || !passed.has(row)) continue;
|
|
1922
|
+
if (Number(row.action_count) > 0 && !await handlers.readable(row)) continue;
|
|
1923
|
+
const position = {
|
|
1924
|
+
key: receivedAt,
|
|
1925
|
+
id
|
|
1926
|
+
};
|
|
1927
|
+
lastReadable = position;
|
|
1928
|
+
if (input.state && row.inbox_state !== input.state) continue;
|
|
1929
|
+
items.push({
|
|
1930
|
+
item: this.itemDTO(row, row.disposition_at),
|
|
1931
|
+
state: String(row.inbox_state),
|
|
1932
|
+
position
|
|
1933
|
+
});
|
|
1934
|
+
if (items.length === limit) break;
|
|
1935
|
+
}
|
|
1936
|
+
const remaining = more || consumed < candidates.length;
|
|
1937
|
+
const full = items.length === limit;
|
|
1938
|
+
if (!remaining || full || examined >= scanCeiling) return {
|
|
1939
|
+
items,
|
|
1940
|
+
scanned: examined,
|
|
1941
|
+
done: !remaining,
|
|
1942
|
+
full,
|
|
1943
|
+
scanFrom,
|
|
1944
|
+
lastReadable
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
/** Associated data that binds a sealed cursor to this authenticated scope. */
|
|
1949
|
+
cursorScope() {
|
|
1950
|
+
return Buffer.from(canonical({
|
|
1951
|
+
tenantId: this.scope.tenantId,
|
|
1952
|
+
confidentialScopeId: this.scope.confidentialScopeId,
|
|
1953
|
+
actorId: this.scope.actorId
|
|
1954
|
+
}));
|
|
1955
|
+
}
|
|
1956
|
+
encodeCursor(position) {
|
|
1957
|
+
if (!this.cursorKey) return encodeItemCursor(position.key, position.id);
|
|
1958
|
+
const body = Buffer.alloc(SEALED_CURSOR_BYTES, 32);
|
|
1959
|
+
const json = Buffer.from(JSON.stringify([position.key, position.id]));
|
|
1960
|
+
if (json.length > SEALED_CURSOR_BYTES) throw new Error("Cursor position too long");
|
|
1961
|
+
json.copy(body);
|
|
1962
|
+
const iv = randomBytes(12);
|
|
1963
|
+
const cipher = createCipheriv("aes-256-gcm", this.cursorKey, iv);
|
|
1964
|
+
cipher.setAAD(this.cursorScope());
|
|
1965
|
+
const sealed = Buffer.concat([cipher.update(body), cipher.final()]);
|
|
1966
|
+
return Buffer.concat([
|
|
1967
|
+
iv,
|
|
1968
|
+
cipher.getAuthTag(),
|
|
1969
|
+
sealed
|
|
1970
|
+
]).toString("base64url");
|
|
1971
|
+
}
|
|
1972
|
+
decodeCursor(cursor) {
|
|
1973
|
+
if (!this.cursorKey) {
|
|
1974
|
+
const { receivedAt, id } = decodeItemCursor(cursor);
|
|
1975
|
+
return {
|
|
1976
|
+
key: receivedAt,
|
|
1977
|
+
id
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
try {
|
|
1981
|
+
const bytes = Buffer.from(cursor, "base64url");
|
|
1982
|
+
if (bytes.length !== 1052) throw new Error("size");
|
|
1983
|
+
const decipher = createDecipheriv("aes-256-gcm", this.cursorKey, bytes.subarray(0, 12));
|
|
1984
|
+
decipher.setAAD(this.cursorScope());
|
|
1985
|
+
decipher.setAuthTag(bytes.subarray(12, 28));
|
|
1986
|
+
const parsed = JSON.parse(Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString("utf8"));
|
|
1987
|
+
if (!Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== "string" || !parsed[0] || typeof parsed[1] !== "string" || !parsed[1]) throw new Error("shape");
|
|
1988
|
+
return {
|
|
1989
|
+
key: parsed[0],
|
|
1990
|
+
id: parsed[1]
|
|
1991
|
+
};
|
|
1992
|
+
} catch {
|
|
1993
|
+
throw new Error("Invalid item page");
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
/**
|
|
1997
|
+
* Every authorized active item, newest receipt first.
|
|
1998
|
+
* @deprecated Use `listItemsPage`. Kept so callers of the array-returning
|
|
1999
|
+
* API keep working. It walks every page, so its cost grows with the scope,
|
|
2000
|
+
* including one execution authorization per (item, handler) across the
|
|
2001
|
+
* whole scope. It follows the internal scan position, which never leaves
|
|
2002
|
+
* the service, so runs of rows the actor cannot read never stop it. Items
|
|
2003
|
+
* are gated exactly as on a page.
|
|
2004
|
+
*/
|
|
1389
2005
|
async listItems() {
|
|
1390
|
-
const
|
|
1391
|
-
|
|
1392
|
-
for (
|
|
1393
|
-
await this.
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
2006
|
+
const all = [];
|
|
2007
|
+
let from;
|
|
2008
|
+
for (;;) {
|
|
2009
|
+
const scan = await this.scanItems({}, 100, ITEM_SCAN_CEILING, from);
|
|
2010
|
+
all.push(...scan.items.map(({ item }) => item));
|
|
2011
|
+
if (scan.done) return all;
|
|
2012
|
+
if (!scan.scanFrom || from && samePosition(scan.scanFrom, from)) throw new Error("Intake list made no progress");
|
|
2013
|
+
from = scan.scanFrom;
|
|
2014
|
+
}
|
|
1397
2015
|
}
|
|
1398
|
-
|
|
2016
|
+
/**
|
|
2017
|
+
* One read authorization per distinct captured ceiling for a page of rows.
|
|
2018
|
+
* A refusal hides the rows; a malformed ceiling hides its row; a host
|
|
2019
|
+
* callback that throws rejects the page with that error.
|
|
2020
|
+
*/
|
|
2021
|
+
ceilingGate() {
|
|
2022
|
+
const ceilings = /* @__PURE__ */ new Map();
|
|
2023
|
+
return (data) => {
|
|
2024
|
+
let ceiling;
|
|
2025
|
+
try {
|
|
2026
|
+
ceiling = object(data.capturedCeiling ?? {});
|
|
2027
|
+
} catch {
|
|
2028
|
+
return Promise.resolve(false);
|
|
2029
|
+
}
|
|
2030
|
+
const key = canonical(ceiling);
|
|
2031
|
+
let decision = ceilings.get(key);
|
|
2032
|
+
if (!decision) {
|
|
2033
|
+
decision = Promise.resolve(this.options.authorize({
|
|
2034
|
+
scope: this.scope,
|
|
2035
|
+
operation: "read",
|
|
2036
|
+
capturedCeiling: ceiling,
|
|
2037
|
+
db: this.db
|
|
2038
|
+
})).then((granted) => granted === true);
|
|
2039
|
+
ceilings.set(key, decision);
|
|
2040
|
+
}
|
|
2041
|
+
return decision;
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* The handler checks `listReviews` applies per action, batched for a page:
|
|
2046
|
+
* the bindings of the rows' current proposals are projected in SQL
|
|
2047
|
+
* (handler id, version, operation and capability; distinct per item) once
|
|
2048
|
+
* per fetched batch, then the execution list read gate runs once per distinct
|
|
2049
|
+
* (item, handler) of a row that passed the item-level gate. An item whose
|
|
2050
|
+
* bound handler the actor cannot currently read, whose binding drifted
|
|
2051
|
+
* from the configured handler, or whose bindings cannot be resolved, is
|
|
2052
|
+
* not listed. Actions without a proposal carry no handler and are not
|
|
2053
|
+
* gated, as in `listReviews`.
|
|
2054
|
+
*/
|
|
2055
|
+
handlerGate() {
|
|
2056
|
+
const bound = /* @__PURE__ */ new Map();
|
|
2057
|
+
let execution;
|
|
2058
|
+
const service = () => {
|
|
2059
|
+
execution ??= (this.options.execution ? this.execution() : Promise.resolve(void 0)).catch(() => void 0);
|
|
2060
|
+
return execution;
|
|
2061
|
+
};
|
|
2062
|
+
const field = (name, text) => this.engine === "sqlite" ? `json_extract(p.data, '$.binding.${name}')` : `(p.data::jsonb)->'binding'${text ? "->>" : "->"}'${name}'`;
|
|
2063
|
+
const parsed = (value) => {
|
|
2064
|
+
if (typeof value !== "string") return value;
|
|
2065
|
+
try {
|
|
2066
|
+
return JSON.parse(value);
|
|
2067
|
+
} catch {
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
};
|
|
2071
|
+
return {
|
|
2072
|
+
resolve: async (rows) => {
|
|
2073
|
+
const pending = rows.filter((row) => !bound.has(String(row.id)));
|
|
2074
|
+
if (!pending.length) return;
|
|
2075
|
+
for (const row of pending) bound.set(String(row.id), []);
|
|
2076
|
+
const found = (await this.db.query(`SELECT DISTINCT p.item_id, ${field("handlerId", true)} AS handler_id, ${field("handlerVersion", true)} AS handler_version, ${field("operation", false)} AS operation, ${field("capability", false)} AS capability FROM intake_proposals p JOIN intake_actions a ON a.tenant_id = p.tenant_id AND a.confidential_scope_id = p.confidential_scope_id AND a.id = p.action_id AND a.proposal_revision = p.revision WHERE p.tenant_id = ? AND p.confidential_scope_id = ? AND p.item_id IN (${pending.map(() => "?").join(",")})`, this.scope.tenantId, this.scope.confidentialScopeId, ...pending.map((row) => String(row.id)))).rows;
|
|
2077
|
+
for (const row of found) bound.get(String(row.item_id))?.push({
|
|
2078
|
+
handlerId: row.handler_id == null ? "" : String(row.handler_id),
|
|
2079
|
+
handlerVersion: row.handler_version == null ? "" : String(row.handler_version),
|
|
2080
|
+
operation: parsed(row.operation),
|
|
2081
|
+
capability: parsed(row.capability)
|
|
2082
|
+
});
|
|
2083
|
+
},
|
|
2084
|
+
readable: async (row) => {
|
|
2085
|
+
for (const binding of bound.get(String(row.id)) ?? []) {
|
|
2086
|
+
const gate = await service();
|
|
2087
|
+
if (!gate || !await gate[listReadGate](row, binding)) return false;
|
|
2088
|
+
}
|
|
2089
|
+
return true;
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
/** Only the disposition state and time; reviewer and reason need review authority. */
|
|
2094
|
+
itemDTO(row, dispositionAt) {
|
|
1399
2095
|
return {
|
|
1400
2096
|
id: String(row.id),
|
|
1401
2097
|
receiptState: String(row.receipt_state),
|
|
1402
2098
|
processingState: String(row.processing_state),
|
|
1403
2099
|
analysisRevision: Number(row.analysis_revision),
|
|
1404
2100
|
cancelled: !!row.cancelled,
|
|
1405
|
-
traceId: String(row.trace_id)
|
|
2101
|
+
traceId: String(row.trace_id),
|
|
2102
|
+
...dispositionAt !== void 0 && dispositionAt !== null ? { disposition: {
|
|
2103
|
+
state: "resolved",
|
|
2104
|
+
at: persistedDate(dispositionAt).toISOString()
|
|
2105
|
+
} } : {}
|
|
1406
2106
|
};
|
|
1407
2107
|
}
|
|
1408
2108
|
async getItem(id) {
|
|
1409
|
-
|
|
2109
|
+
const item = await this.item(id, "read");
|
|
2110
|
+
const [disposition] = await this.rows(this.db, "intake_dispositions", "item_id=? ORDER BY id LIMIT 1", [item.id]);
|
|
2111
|
+
return this.itemDTO(item, disposition?.created_at);
|
|
1410
2112
|
}
|
|
1411
2113
|
async getEvidence(itemId) {
|
|
1412
2114
|
await this.item(itemId, "read");
|
|
1413
|
-
return (await this.rows(this.db, "intake_evidence", "item_id=? AND state='durable'", [itemId])).map((e) => (
|
|
2115
|
+
return (await this.rows(this.db, "intake_evidence", "item_id=? AND state='durable'", [itemId])).map((e) => this.evidenceDTO(e));
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Durable evidence for one inbox page in a single query, keyed by item id.
|
|
2119
|
+
* Items that are not active, retained and readable are omitted (no
|
|
2120
|
+
* existence disclosure); the host authorize callback runs once per distinct
|
|
2121
|
+
* captured ceiling rather than once per item, through the same gate as
|
|
2122
|
+
* `listItemsPage`: a refusal or a malformed ceiling hides the item, and a
|
|
2123
|
+
* host callback that throws rejects the read.
|
|
2124
|
+
*/
|
|
2125
|
+
async listEvidence(itemIds) {
|
|
2126
|
+
if (!Array.isArray(itemIds) || itemIds.length > 100 || !itemIds.every(isIntakeId)) throw new Error("Invalid item page");
|
|
2127
|
+
const result = /* @__PURE__ */ new Map();
|
|
2128
|
+
if (!itemIds.length) return result;
|
|
2129
|
+
const rows = (await this.db.query(`SELECT e.*, i.data AS item_data FROM intake_evidence e JOIN intake_items i ON i.tenant_id = e.tenant_id AND i.confidential_scope_id = e.confidential_scope_id AND i.id = e.item_id WHERE e.tenant_id = ? AND e.confidential_scope_id = ? AND e.state = 'durable' AND i.visibility = 'active' AND i.expires_at > ? AND e.item_id IN (${itemIds.map(() => "?").join(",")}) ORDER BY e.item_id, e.part_id`, this.scope.tenantId, this.scope.confidentialScopeId, this.now().toISOString(), ...itemIds)).rows;
|
|
2130
|
+
const allowed = this.ceilingGate();
|
|
2131
|
+
for (const row of rows) {
|
|
2132
|
+
let data;
|
|
2133
|
+
try {
|
|
2134
|
+
data = object(row.item_data);
|
|
2135
|
+
} catch {
|
|
2136
|
+
continue;
|
|
2137
|
+
}
|
|
2138
|
+
if (!await allowed(data)) continue;
|
|
2139
|
+
const itemId = String(row.item_id);
|
|
2140
|
+
const parts = result.get(itemId) ?? [];
|
|
2141
|
+
parts.push(this.evidenceDTO(row));
|
|
2142
|
+
result.set(itemId, parts);
|
|
2143
|
+
}
|
|
2144
|
+
return result;
|
|
2145
|
+
}
|
|
2146
|
+
evidenceDTO(e) {
|
|
2147
|
+
const filename2 = presentedFilename(object(e.data).filename);
|
|
2148
|
+
return {
|
|
1414
2149
|
id: String(e.id),
|
|
1415
2150
|
partId: String(e.part_id),
|
|
1416
2151
|
parentEvidenceId: e.parent_evidence_id ? String(e.parent_evidence_id) : null,
|
|
1417
2152
|
mediaType: String(e.media_type),
|
|
1418
2153
|
contentHash: String(e.content_hash),
|
|
1419
|
-
byteLength: Number(e.byte_length)
|
|
1420
|
-
|
|
2154
|
+
byteLength: Number(e.byte_length),
|
|
2155
|
+
...filename2 ? { filename: filename2 } : {}
|
|
2156
|
+
};
|
|
1421
2157
|
}
|
|
1422
2158
|
async readEvidence(itemId, evidenceId) {
|
|
1423
2159
|
await this.item(itemId, "read");
|
|
1424
2160
|
const [e] = await this.rows(this.db, "intake_evidence", "item_id=? AND id=? AND state='durable'", [itemId, evidenceId]);
|
|
1425
2161
|
if (!e) throw new Error("Intake unavailable");
|
|
1426
|
-
const
|
|
1427
|
-
if (!asset || asset.tenantId !== this.scope.tenantId || asset.sourceUri !== e.source_uri) throw new Error("Intake unavailable");
|
|
1428
|
-
const bytes = await this.options.assets.store.read(asset);
|
|
2162
|
+
const bytes = await this.evidenceBytes(e);
|
|
1429
2163
|
if (bytes.length !== Number(e.byte_length) || bytesHash(bytes) !== e.content_hash) throw new Error("Intake integrity");
|
|
1430
2164
|
await this.item(itemId, "read");
|
|
1431
2165
|
return bytes;
|
|
1432
2166
|
}
|
|
2167
|
+
async evidenceBytes(e) {
|
|
2168
|
+
const data = object(e.data);
|
|
2169
|
+
if (data.retain === "reference") {
|
|
2170
|
+
if (e.asset_id) throw new Error("Intake integrity");
|
|
2171
|
+
if (typeof data.snapshot !== "string") throw new Error("Intake unavailable");
|
|
2172
|
+
return Buffer.from(data.snapshot, "utf8");
|
|
2173
|
+
}
|
|
2174
|
+
if (!e.asset_id) throw new Error("Intake unavailable");
|
|
2175
|
+
const asset = await withTenant({ tenantId: this.scope.tenantId }, () => this.options.assets.collection.get({ id: String(e.asset_id) }));
|
|
2176
|
+
if (!asset || asset.tenantId !== this.scope.tenantId || asset.sourceUri !== e.source_uri) throw new Error("Intake unavailable");
|
|
2177
|
+
return this.options.assets.store.read(asset);
|
|
2178
|
+
}
|
|
1433
2179
|
/** Append an immutable input revision. Reprocessing never creates actions. */
|
|
1434
2180
|
async analyze(itemId, configuration, requestKey) {
|
|
1435
2181
|
const frozen = structuredClone(configuration);
|
|
1436
2182
|
if (Object.hasOwn(frozen, "humanCorrection") && frozen.stage !== "interpret") throw new Error("Human corrections require authenticated split revision");
|
|
2183
|
+
if (Object.hasOwn(frozen, "reviewerInstruction") && frozen.stage !== "interpret") throw new Error("Reviewer instructions require authenticated instruction revision");
|
|
1437
2184
|
return this.tx((db) => this.appendAnalysis(db, itemId, frozen, requestKey));
|
|
1438
2185
|
}
|
|
1439
|
-
async appendAnalysis(db, itemId, configuration, requestKey) {
|
|
2186
|
+
async appendAnalysis(db, itemId, configuration, requestKey, pinnedSourceAttemptId) {
|
|
1440
2187
|
nonempty(requestKey);
|
|
1441
2188
|
const configDigest = digest(configuration);
|
|
1442
2189
|
await this.lock(db, itemId);
|
|
1443
2190
|
const item = await this.item(itemId, "process", db);
|
|
2191
|
+
await this.assertUnresolved(db, itemId);
|
|
1444
2192
|
if (!await this.matchesDispatchRoute(db, item)) throw new Error("Intake dispatch route unavailable");
|
|
1445
2193
|
await this.bindDispatchRoute(db, item);
|
|
1446
2194
|
if (item.receipt_state !== "ready" || item.cancelled) throw new Error("Intake not processable");
|
|
1447
|
-
const
|
|
2195
|
+
const previous = await this.rows(db, "intake_analyses", "item_id=?", [itemId]);
|
|
2196
|
+
const replay = previous.find((a) => object(a.data).requestKey === requestKey);
|
|
1448
2197
|
if (replay) {
|
|
1449
2198
|
if (object(replay.data).configDigest !== configDigest) throw new Error("Analysis request conflict");
|
|
1450
2199
|
return {
|
|
@@ -1452,6 +2201,13 @@ var IngestionService = class {
|
|
|
1452
2201
|
revision: Number(replay.revision)
|
|
1453
2202
|
};
|
|
1454
2203
|
}
|
|
2204
|
+
if (configuration.stage === "interpret") {
|
|
2205
|
+
if (Number(object(object(configuration.proposals).source).revision) !== previous.filter((row) => object(object(row.data).configuration).stage !== "interpret").reduce((max, row) => Math.max(max, Number(row.revision)), Number.NEGATIVE_INFINITY)) throw new GenerationSourceStaleError();
|
|
2206
|
+
}
|
|
2207
|
+
if (pinnedSourceAttemptId !== void 0) {
|
|
2208
|
+
const latest = previous.find((a) => Number(a.revision) === Number(item.analysis_revision));
|
|
2209
|
+
if (!latest || latest.current_attempt_id !== pinnedSourceAttemptId) throw new Error("Generation source changed");
|
|
2210
|
+
}
|
|
1455
2211
|
const evidence = await this.rows(db, "intake_evidence", "item_id=? AND state='durable'", [itemId]);
|
|
1456
2212
|
await db.query("UPDATE intake_analysis_attempts SET state='superseded' WHERE tenant_id=? AND confidential_scope_id=? AND item_id=? AND state IN ('queued','running')", this.scope.tenantId, this.scope.confidentialScopeId, itemId);
|
|
1457
2213
|
await db.query("UPDATE intake_analyses SET state='superseded' WHERE tenant_id=? AND confidential_scope_id=? AND item_id=? AND state IN ('queued','running')", this.scope.tenantId, this.scope.confidentialScopeId, itemId);
|
|
@@ -1484,7 +2240,7 @@ var IngestionService = class {
|
|
|
1484
2240
|
item_id: itemId,
|
|
1485
2241
|
stage: "analyze",
|
|
1486
2242
|
revision,
|
|
1487
|
-
state: "pending",
|
|
2243
|
+
state: object(item.data).dispatch === "inline" ? "completed" : "pending",
|
|
1488
2244
|
job_id: "",
|
|
1489
2245
|
deliveries: 0,
|
|
1490
2246
|
data: canonical({ dispatchTarget: this.dispatchTarget })
|
|
@@ -1494,6 +2250,107 @@ var IngestionService = class {
|
|
|
1494
2250
|
revision
|
|
1495
2251
|
};
|
|
1496
2252
|
}
|
|
2253
|
+
/** Run one fenced stage in-process and reload its terminal projection. */
|
|
2254
|
+
async inlineStage(itemId, revision, work) {
|
|
2255
|
+
const lease = await this.claimAnalysis(itemId, revision, "inline");
|
|
2256
|
+
if (lease) await work(lease);
|
|
2257
|
+
return this.tx(async (db) => {
|
|
2258
|
+
await this.lock(db, itemId);
|
|
2259
|
+
await this.item(itemId, "process", db);
|
|
2260
|
+
const [analysis] = await this.rows(db, "intake_analyses", "item_id=? AND revision=?", [itemId, revision]);
|
|
2261
|
+
if (!analysis) throw new Error("Analysis unavailable");
|
|
2262
|
+
const [attempt] = analysis.current_attempt_id ? await this.rows(db, "intake_analysis_attempts", "id=? AND item_id=?", [analysis.current_attempt_id, itemId]) : [];
|
|
2263
|
+
if (!attempt || ![
|
|
2264
|
+
"completed",
|
|
2265
|
+
"partial",
|
|
2266
|
+
"needs_attention",
|
|
2267
|
+
"failed"
|
|
2268
|
+
].includes(String(attempt.state))) throw new Error("Inline interpretation busy");
|
|
2269
|
+
return {
|
|
2270
|
+
analysisId: String(analysis.id),
|
|
2271
|
+
revision,
|
|
2272
|
+
attemptId: String(attempt.id),
|
|
2273
|
+
state: String(attempt.state),
|
|
2274
|
+
error: attempt.safe_error ? String(attempt.safe_error) : null
|
|
2275
|
+
};
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
/** Documented in-process receive-to-proposals path for `dispatch: 'inline'`
|
|
2279
|
+
* receipts. Extraction (UTF-8 text/structured only) and interpretation use the
|
|
2280
|
+
* same immutable revisions, fenced leases, catalog-live checks, provenance and
|
|
2281
|
+
* failure categories as the job path; no queue intent is created or delivered. */
|
|
2282
|
+
async interpretInline(itemId, input) {
|
|
2283
|
+
nonempty(input.requestId);
|
|
2284
|
+
if (!storableText(input.requestId)) throw new Error("Invalid intake identifier");
|
|
2285
|
+
if (typeof input.extraction?.configurationRevision !== "string" || !input.extraction.configurationRevision || !storableText(input.extraction.configurationRevision)) throw new Error("Invalid extraction configuration");
|
|
2286
|
+
if (object((await this.item(itemId, "process")).data).dispatch !== "inline") throw new Error("Inline interpretation requires an inline receipt");
|
|
2287
|
+
const extraction = {
|
|
2288
|
+
configurationRevision: input.extraction.configurationRevision,
|
|
2289
|
+
limits: structuredClone(input.extraction.limits)
|
|
2290
|
+
};
|
|
2291
|
+
const requested = await this.analyze(itemId, {
|
|
2292
|
+
stage: "extract",
|
|
2293
|
+
extraction
|
|
2294
|
+
}, `inline:extract:${input.requestId}`);
|
|
2295
|
+
const extracted = await this.inlineStage(itemId, requested.revision, (lease) => extractAnalysis(this, lease, { extract: (request) => extractWithProviders(request, {}) }, extraction));
|
|
2296
|
+
if (!["completed", "partial"].includes(extracted.state)) return {
|
|
2297
|
+
itemId,
|
|
2298
|
+
extraction: extracted
|
|
2299
|
+
};
|
|
2300
|
+
const interpretKey = `inline:interpret:${input.requestId}`;
|
|
2301
|
+
const interpretation = await this.analysisByRequest(itemId, interpretKey) ?? await this.appendInterpretation(itemId, extracted.attemptId, interpretKey);
|
|
2302
|
+
const generation = await this.inlineStage(itemId, interpretation.revision, (lease) => this.generateProposals(lease));
|
|
2303
|
+
if (!["completed", "partial"].includes(generation.state)) return {
|
|
2304
|
+
itemId,
|
|
2305
|
+
extraction: extracted,
|
|
2306
|
+
generation
|
|
2307
|
+
};
|
|
2308
|
+
return {
|
|
2309
|
+
itemId,
|
|
2310
|
+
extraction: extracted,
|
|
2311
|
+
generation,
|
|
2312
|
+
analysis: await this.getCompletedAnalysis(itemId, generation.attemptId)
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
/** Prepare outside the lock (it reads catalog and analysis), then append
|
|
2316
|
+
* under it with the source pin rechecked. A concurrent identical request
|
|
2317
|
+
* that appended first makes preparation fail; its revision is the replay. */
|
|
2318
|
+
async appendInterpretation(itemId, sourceAttemptId, requestKey) {
|
|
2319
|
+
await this.point("inline:replay-miss");
|
|
2320
|
+
let configuration;
|
|
2321
|
+
try {
|
|
2322
|
+
configuration = structuredClone(await this.prepareGeneration(itemId, sourceAttemptId));
|
|
2323
|
+
} catch (error) {
|
|
2324
|
+
const replay = await this.analysisByRequest(itemId, requestKey);
|
|
2325
|
+
if (replay) return replay;
|
|
2326
|
+
throw error;
|
|
2327
|
+
}
|
|
2328
|
+
await this.point("inline:prepared");
|
|
2329
|
+
return this.tx((db) => this.appendAnalysis(db, itemId, configuration, requestKey, sourceAttemptId));
|
|
2330
|
+
}
|
|
2331
|
+
async analysisByRequest(itemId, requestKey) {
|
|
2332
|
+
const found = (await this.rows(this.db, "intake_analyses", "item_id=?", [itemId])).find((row) => object(row.data).requestKey === requestKey);
|
|
2333
|
+
return found ? {
|
|
2334
|
+
id: String(found.id),
|
|
2335
|
+
revision: Number(found.revision)
|
|
2336
|
+
} : null;
|
|
2337
|
+
}
|
|
2338
|
+
/** Owner-lifecycle hook: expire every active receipt bound to a deleted owner
|
|
2339
|
+
* through the ordinary revoke-then-delete path. Returns the number scheduled. */
|
|
2340
|
+
async expireOwned(owner) {
|
|
2341
|
+
const key = ownerKey(owner);
|
|
2342
|
+
const items = await this.rows(this.db, "intake_items", "owner_key=? AND visibility='active'", [key]);
|
|
2343
|
+
try {
|
|
2344
|
+
for (const item of items) await this.scheduleExpiry(String(item.id));
|
|
2345
|
+
} catch (error) {
|
|
2346
|
+
try {
|
|
2347
|
+
await this.repairDeletions();
|
|
2348
|
+
} catch {}
|
|
2349
|
+
throw error;
|
|
2350
|
+
}
|
|
2351
|
+
await this.repairDeletions();
|
|
2352
|
+
return items.length;
|
|
2353
|
+
}
|
|
1497
2354
|
/** Record an authenticated split correction and queue fresh extraction, never copy model output. */
|
|
1498
2355
|
async reviseLogicalSplit(input) {
|
|
1499
2356
|
const frozen = structuredClone(input);
|
|
@@ -1511,6 +2368,7 @@ var IngestionService = class {
|
|
|
1511
2368
|
const replay = await this.tx(async (db) => {
|
|
1512
2369
|
await this.lock(db, frozen.itemId);
|
|
1513
2370
|
await this.item(frozen.itemId, "process", db);
|
|
2371
|
+
await this.assertUnresolved(db, frozen.itemId);
|
|
1514
2372
|
const found = (await this.rows(db, "intake_analyses", "item_id=?", [frozen.itemId])).find((row) => object(row.data).requestKey === requestKey);
|
|
1515
2373
|
if (!found) return null;
|
|
1516
2374
|
if (digest(object(object(found.data).configuration).humanCorrection) !== digest(correction)) throw new Error("Split request conflict");
|
|
@@ -1523,9 +2381,22 @@ var IngestionService = class {
|
|
|
1523
2381
|
await (await this.proposalReader())[generationSnapshotRead](frozen.itemId, (work) => this.tx(work), (db) => this.completedAnalysisSnapshot(db, frozen.itemId, frozen.attemptId, false), void 0, true);
|
|
1524
2382
|
return replay;
|
|
1525
2383
|
}
|
|
2384
|
+
await this.point("split:checked");
|
|
1526
2385
|
let result;
|
|
1527
|
-
await (await this.proposalReader())[generationSnapshotRead](frozen.itemId, (work) => this.tx(work), (db) => this.completedAnalysisSnapshot(db, frozen.itemId, frozen.attemptId,
|
|
1528
|
-
|
|
2386
|
+
await (await this.proposalReader())[generationSnapshotRead](frozen.itemId, (work) => this.tx(work), (db) => this.completedAnalysisSnapshot(db, frozen.itemId, frozen.attemptId, false), async (db, current) => {
|
|
2387
|
+
await this.lock(db, frozen.itemId);
|
|
2388
|
+
await this.point("split:locked");
|
|
2389
|
+
const [found] = (await this.rows(db, "intake_analyses", "item_id=?", [frozen.itemId])).filter((row) => object(row.data).requestKey === requestKey);
|
|
2390
|
+
if (found) {
|
|
2391
|
+
if (digest(object(object(found.data).configuration).humanCorrection) !== digest(correction)) throw new Error("Split request conflict");
|
|
2392
|
+
result = {
|
|
2393
|
+
id: String(found.id),
|
|
2394
|
+
revision: Number(found.revision)
|
|
2395
|
+
};
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
const item = await this.item(frozen.itemId, "process", db);
|
|
2399
|
+
if (current.revision !== frozen.expectedRevision || Number(item.analysis_revision) !== frozen.expectedRevision) throw new Error("Stale logical split");
|
|
1529
2400
|
const pin = object(object(current.configuration.proposals).source);
|
|
1530
2401
|
const source = await this.completedAnalysisSnapshot(db, frozen.itemId, String(pin.attemptId), false);
|
|
1531
2402
|
for (const key of [
|
|
@@ -1550,6 +2421,7 @@ var IngestionService = class {
|
|
|
1550
2421
|
}
|
|
1551
2422
|
}
|
|
1552
2423
|
if (!observed.size || pages.length !== observed.size || pages.some((page) => !observed.has(page))) throw new Error("Split must cover observed pages exactly");
|
|
2424
|
+
await this.point("split:guarded");
|
|
1553
2425
|
result = await this.appendAnalysis(db, frozen.itemId, {
|
|
1554
2426
|
...source.configuration,
|
|
1555
2427
|
humanCorrection: correction
|
|
@@ -1558,6 +2430,210 @@ var IngestionService = class {
|
|
|
1558
2430
|
if (!result) throw new Error("Split unavailable");
|
|
1559
2431
|
return result;
|
|
1560
2432
|
}
|
|
2433
|
+
/**
|
|
2434
|
+
* Record an authenticated reviewer instruction as an immutable human correction
|
|
2435
|
+
* and queue a fresh extraction plus interpretation that carries it as trusted
|
|
2436
|
+
* guidance. The text steers the model only; it never changes the catalog,
|
|
2437
|
+
* candidates, schemas, permissions or automation eligibility.
|
|
2438
|
+
*/
|
|
2439
|
+
async recordReviewerInstruction(input) {
|
|
2440
|
+
const frozen = structuredClone(input);
|
|
2441
|
+
nonempty(frozen.requestId);
|
|
2442
|
+
nonempty(frozen.attemptId);
|
|
2443
|
+
integer(frozen.expectedRevision);
|
|
2444
|
+
if (typeof frozen.text !== "string" || !frozen.text.trim() || frozen.text.length > 4e3) throw new ReviewerInstructionInvalidError();
|
|
2445
|
+
const requestKey = `instruction:${frozen.requestId}`;
|
|
2446
|
+
const intent = {
|
|
2447
|
+
kind: "reviewer_instruction",
|
|
2448
|
+
itemId: frozen.itemId,
|
|
2449
|
+
attemptId: frozen.attemptId,
|
|
2450
|
+
expectedRevision: frozen.expectedRevision,
|
|
2451
|
+
requestId: frozen.requestId,
|
|
2452
|
+
actorId: this.scope.actorId,
|
|
2453
|
+
text: frozen.text
|
|
2454
|
+
};
|
|
2455
|
+
const replayIntent = (record) => {
|
|
2456
|
+
const { id: _id, recordedAt: _recordedAt, supersedesId: _supersedesId, digest: _digest, ...rest } = record;
|
|
2457
|
+
return rest;
|
|
2458
|
+
};
|
|
2459
|
+
const replay = await this.tx(async (db) => {
|
|
2460
|
+
await this.lock(db, frozen.itemId);
|
|
2461
|
+
await this.item(frozen.itemId, "process", db);
|
|
2462
|
+
const found = (await this.rows(db, "intake_analyses", "item_id=?", [frozen.itemId])).find((row) => object(row.data).requestKey === requestKey);
|
|
2463
|
+
if (!found) return null;
|
|
2464
|
+
const record = object(object(object(found.data).configuration).reviewerInstruction);
|
|
2465
|
+
if (digest(replayIntent(record)) !== digest(intent)) throw new Error("Reviewer instruction request conflict");
|
|
2466
|
+
return {
|
|
2467
|
+
id: String(found.id),
|
|
2468
|
+
revision: Number(found.revision),
|
|
2469
|
+
instruction: reviewerInstructionView(record)
|
|
2470
|
+
};
|
|
2471
|
+
});
|
|
2472
|
+
if (replay) {
|
|
2473
|
+
await (await this.proposalReader())[generationSnapshotRead](frozen.itemId, (work) => this.tx(work), (db) => this.completedAnalysisSnapshot(db, frozen.itemId, frozen.attemptId, false), void 0, true);
|
|
2474
|
+
return replay;
|
|
2475
|
+
}
|
|
2476
|
+
await this.point("instruction:checked");
|
|
2477
|
+
let result;
|
|
2478
|
+
await (await this.proposalReader())[generationSnapshotRead](frozen.itemId, (work) => this.tx(work), (db) => this.completedAnalysisSnapshot(db, frozen.itemId, frozen.attemptId, false), async (db, current) => {
|
|
2479
|
+
await this.lock(db, frozen.itemId);
|
|
2480
|
+
await this.point("instruction:locked");
|
|
2481
|
+
const [found] = (await this.rows(db, "intake_analyses", "item_id=?", [frozen.itemId])).filter((row) => object(row.data).requestKey === requestKey);
|
|
2482
|
+
if (found) {
|
|
2483
|
+
const record2 = object(object(object(found.data).configuration).reviewerInstruction);
|
|
2484
|
+
if (digest(replayIntent(record2)) !== digest(intent)) throw new Error("Reviewer instruction request conflict");
|
|
2485
|
+
result = {
|
|
2486
|
+
id: String(found.id),
|
|
2487
|
+
revision: Number(found.revision),
|
|
2488
|
+
instruction: reviewerInstructionView(record2)
|
|
2489
|
+
};
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
const item = await this.item(frozen.itemId, "process", db);
|
|
2493
|
+
if (Number(item.analysis_revision) !== frozen.expectedRevision) throw new ReviewerInstructionStaleError();
|
|
2494
|
+
let carriedConfiguration;
|
|
2495
|
+
if (current.revision !== frozen.expectedRevision) {
|
|
2496
|
+
const later = await this.laterWork(db, item, current.revision);
|
|
2497
|
+
if (later.phase === "in_flight" || later.phase === "superseded") throw new ReviewerInstructionStaleError();
|
|
2498
|
+
carriedConfiguration = later.configuration;
|
|
2499
|
+
}
|
|
2500
|
+
const pin = object(object(current.configuration.proposals).source);
|
|
2501
|
+
const source = await this.completedAnalysisSnapshot(db, frozen.itemId, String(pin.attemptId), false);
|
|
2502
|
+
for (const key of [
|
|
2503
|
+
"attemptId",
|
|
2504
|
+
"revision",
|
|
2505
|
+
"inputDigest",
|
|
2506
|
+
"outputDigest",
|
|
2507
|
+
"evidenceDigest"
|
|
2508
|
+
]) if (pin[key] !== source[key]) throw new Error("Instruction source changed");
|
|
2509
|
+
const [previous] = await this.recordedInstructions(db, frozen.itemId);
|
|
2510
|
+
const body = {
|
|
2511
|
+
...intent,
|
|
2512
|
+
id: randomUUID(),
|
|
2513
|
+
recordedAt: date(this.now()),
|
|
2514
|
+
supersedesId: previous?.id ?? null
|
|
2515
|
+
};
|
|
2516
|
+
const record = {
|
|
2517
|
+
...body,
|
|
2518
|
+
digest: digest(body)
|
|
2519
|
+
};
|
|
2520
|
+
await this.point("instruction:guarded");
|
|
2521
|
+
result = {
|
|
2522
|
+
...await this.appendAnalysis(db, frozen.itemId, {
|
|
2523
|
+
...carriedConfiguration ?? source.configuration,
|
|
2524
|
+
reviewerInstruction: record
|
|
2525
|
+
}, requestKey),
|
|
2526
|
+
instruction: reviewerInstructionView(record)
|
|
2527
|
+
};
|
|
2528
|
+
}, true);
|
|
2529
|
+
if (!result) throw new Error("Reviewer instruction unavailable");
|
|
2530
|
+
return result;
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* How the revisions after an anchor generation relate to it. Shared by the
|
|
2534
|
+
* retry guard and getReinterpretationState so they cannot disagree.
|
|
2535
|
+
* A completed extraction with no interpretation appended is in flight until
|
|
2536
|
+
* its attempt's completion plus the item's analysis lease duration
|
|
2537
|
+
* (`limits.leaseMs`, the same lease claimAnalysis grants); after that it is
|
|
2538
|
+
* treated as a reinterpretation that did not start.
|
|
2539
|
+
*/
|
|
2540
|
+
async laterWork(db, item, anchorRevision) {
|
|
2541
|
+
const later = (await this.rows(db, "intake_analyses", "item_id=? AND revision>?", [String(item.id), anchorRevision])).sort((a, b) => Number(b.revision) - Number(a.revision));
|
|
2542
|
+
const stage = (row) => object(object(row.data).configuration).stage;
|
|
2543
|
+
const newestExtraction = later.find((row) => stage(row) !== "interpret");
|
|
2544
|
+
const configuration = newestExtraction ? object(object(newestExtraction.data).configuration) : void 0;
|
|
2545
|
+
const [latest] = later;
|
|
2546
|
+
if (!latest) return {
|
|
2547
|
+
phase: "none",
|
|
2548
|
+
failure: null,
|
|
2549
|
+
configuration
|
|
2550
|
+
};
|
|
2551
|
+
if (later.some((row) => ["completed", "partial"].includes(String(row.state)) && stage(row) === "interpret")) return {
|
|
2552
|
+
phase: "superseded",
|
|
2553
|
+
failure: null,
|
|
2554
|
+
configuration
|
|
2555
|
+
};
|
|
2556
|
+
if (later.some((row) => ["queued", "running"].includes(String(row.state)))) return {
|
|
2557
|
+
phase: "in_flight",
|
|
2558
|
+
failure: null,
|
|
2559
|
+
configuration
|
|
2560
|
+
};
|
|
2561
|
+
const [attempt] = latest.current_attempt_id ? await this.rows(db, "intake_analysis_attempts", "id=? AND item_id=?", [latest.current_attempt_id, String(item.id)]) : [];
|
|
2562
|
+
if (["completed", "partial"].includes(String(latest.state))) {
|
|
2563
|
+
const completedAt = attempt ? object(attempt.data).completedAt : void 0;
|
|
2564
|
+
const leaseMs = Number(object(object(item.data).limits).leaseMs);
|
|
2565
|
+
const started = typeof completedAt === "string" ? persistedDate(completedAt).getTime() : NaN;
|
|
2566
|
+
const deadline = Number.isFinite(started) && Number.isFinite(leaseMs) ? started + leaseMs : Number.NEGATIVE_INFINITY;
|
|
2567
|
+
return this.now().getTime() <= deadline ? {
|
|
2568
|
+
phase: "in_flight",
|
|
2569
|
+
failure: null,
|
|
2570
|
+
configuration
|
|
2571
|
+
} : {
|
|
2572
|
+
phase: "failed",
|
|
2573
|
+
failure: "not_started",
|
|
2574
|
+
configuration
|
|
2575
|
+
};
|
|
2576
|
+
}
|
|
2577
|
+
const safe = attempt?.safe_error ? String(attempt.safe_error) : "";
|
|
2578
|
+
return {
|
|
2579
|
+
phase: "failed",
|
|
2580
|
+
failure: failures.has(safe) ? safe : null,
|
|
2581
|
+
configuration
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2584
|
+
/** Pollable reinterpretation state: current revision, safe failure and the retry anchor. */
|
|
2585
|
+
async getReinterpretationState(itemId) {
|
|
2586
|
+
const item = await this.item(itemId, "read");
|
|
2587
|
+
const analyses = (await this.rows(this.db, "intake_analyses", "item_id=?", [itemId])).sort((a, b) => Number(b.revision) - Number(a.revision));
|
|
2588
|
+
const generation = analyses.find((row) => ["completed", "partial"].includes(String(row.state)) && row.current_attempt_id && object(object(row.data).configuration).stage === "interpret");
|
|
2589
|
+
let failure = null;
|
|
2590
|
+
let reinterpreting = false;
|
|
2591
|
+
if (generation) {
|
|
2592
|
+
const later = await this.laterWork(this.db, item, Number(generation.revision));
|
|
2593
|
+
failure = later.failure;
|
|
2594
|
+
reinterpreting = later.phase === "in_flight";
|
|
2595
|
+
} else {
|
|
2596
|
+
const latest = analyses[0];
|
|
2597
|
+
reinterpreting = ["queued", "running"].includes(String(latest?.state));
|
|
2598
|
+
if (latest && String(latest.state) === "needs_attention" && latest.current_attempt_id) {
|
|
2599
|
+
const [attempt] = await this.rows(this.db, "intake_analysis_attempts", "id=? AND item_id=?", [latest.current_attempt_id, itemId]);
|
|
2600
|
+
if (attempt?.safe_error && failures.has(String(attempt.safe_error))) failure = String(attempt.safe_error);
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
const newestExtraction = analyses.find((row) => object(object(row.data).configuration).stage !== "interpret");
|
|
2604
|
+
const carried = newestExtraction ? object(object(newestExtraction.data).configuration).reviewerInstruction : void 0;
|
|
2605
|
+
const instructionInEffect = carried ? reviewerInstructionView(carried).id : null;
|
|
2606
|
+
await this.item(itemId, "read");
|
|
2607
|
+
return {
|
|
2608
|
+
revision: Number(item.analysis_revision),
|
|
2609
|
+
processingState: String(item.processing_state),
|
|
2610
|
+
failure,
|
|
2611
|
+
reinterpreting,
|
|
2612
|
+
instructionInEffect,
|
|
2613
|
+
generationAttemptId: generation ? String(generation.current_attempt_id) : null
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
/** Immutable instruction history, newest first. History is never deleted. */
|
|
2617
|
+
async listReviewerInstructions(itemId) {
|
|
2618
|
+
await this.item(itemId, "read");
|
|
2619
|
+
const history = await this.recordedInstructions(this.db, itemId);
|
|
2620
|
+
await this.item(itemId, "read");
|
|
2621
|
+
return history;
|
|
2622
|
+
}
|
|
2623
|
+
async recordedInstructions(db, itemId) {
|
|
2624
|
+
const rows = await this.rows(db, "intake_analyses", "item_id=?", [itemId]);
|
|
2625
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2626
|
+
const history = [];
|
|
2627
|
+
for (const row of rows.sort((a, b) => Number(b.revision) - Number(a.revision))) {
|
|
2628
|
+
const configuration = object(object(row.data).configuration);
|
|
2629
|
+
if (configuration.stage === "interpret" || !configuration.reviewerInstruction) continue;
|
|
2630
|
+
const instruction = reviewerInstructionView(configuration.reviewerInstruction);
|
|
2631
|
+
if (seen.has(instruction.id)) continue;
|
|
2632
|
+
seen.add(instruction.id);
|
|
2633
|
+
history.push(instruction);
|
|
2634
|
+
}
|
|
2635
|
+
return history;
|
|
2636
|
+
}
|
|
1561
2637
|
/** Allocate a fenced attempt; active leases cannot be stolen before expiry. */
|
|
1562
2638
|
async claimAnalysis(itemId, revision, workerId) {
|
|
1563
2639
|
integer(revision);
|
|
@@ -1565,7 +2641,7 @@ var IngestionService = class {
|
|
|
1565
2641
|
return this.tx(async (db) => {
|
|
1566
2642
|
await this.lock(db, itemId);
|
|
1567
2643
|
const item = await this.item(itemId, "process", db);
|
|
1568
|
-
if (item.cancelled || item.receipt_state !== "ready" || Number(item.analysis_revision) !== revision) return null;
|
|
2644
|
+
if (item.cancelled || item.receipt_state !== "ready" || Number(item.analysis_revision) !== revision || (await this.rows(db, "intake_dispositions", "item_id=?", [itemId])).length) return null;
|
|
1569
2645
|
const [analysis] = await this.rows(db, "intake_analyses", "item_id=? AND revision=?", [itemId, revision]);
|
|
1570
2646
|
if (!analysis || ["completed", "partial"].includes(String(analysis.state))) return null;
|
|
1571
2647
|
const limits = object(object(item.data).limits);
|
|
@@ -1697,7 +2773,7 @@ var IngestionService = class {
|
|
|
1697
2773
|
"outputDigest",
|
|
1698
2774
|
"evidenceDigest"
|
|
1699
2775
|
]) if (pin[key] !== source[key]) throw new Error("Generation source changed");
|
|
1700
|
-
if (source.result.provider !== "smrt-ingestion-extraction" || source.configuration.stage === "interpret" || source.revision !== generation.revision - 1 || digest(configuration.humanCorrection ?? null) !== digest(source.configuration.humanCorrection ?? null) || digest(generation.evidence.map((entry) => ({
|
|
2776
|
+
if (source.result.provider !== "smrt-ingestion-extraction" || source.configuration.stage === "interpret" || source.revision !== generation.revision - 1 || digest(configuration.humanCorrection ?? null) !== digest(source.configuration.humanCorrection ?? null) || digest(configuration.reviewerInstruction ?? null) !== digest(source.configuration.reviewerInstruction ?? null) || digest(generation.evidence.map((entry) => ({
|
|
1701
2777
|
id: entry.id,
|
|
1702
2778
|
hash: entry.contentHash
|
|
1703
2779
|
}))) !== source.evidenceDigest) throw new Error("Generation evidence changed");
|
|
@@ -1829,10 +2905,12 @@ var IngestionService = class {
|
|
|
1829
2905
|
/** Mint an action only on an explicit host intention, never during reanalysis. */
|
|
1830
2906
|
async createAction(itemId, actionKey, provenance) {
|
|
1831
2907
|
nonempty(actionKey);
|
|
2908
|
+
if (actionKey.startsWith("reviewer:") || provenance.origin === "reviewer-selected") throw new Error("Reserved action provenance");
|
|
1832
2909
|
const payload = canonical(provenance);
|
|
1833
2910
|
return this.tx(async (db) => {
|
|
1834
2911
|
await this.lock(db, itemId);
|
|
1835
2912
|
if ((await this.item(itemId, "process", db)).cancelled) throw new Error("Intake cancelled");
|
|
2913
|
+
await this.assertUnresolved(db, itemId);
|
|
1836
2914
|
const [existing] = await this.rows(db, "intake_actions", "item_id=? AND action_key=?", [itemId, actionKey]);
|
|
1837
2915
|
if (existing) {
|
|
1838
2916
|
if (canonical(object(existing.data)) !== payload) throw new Error("Action identity conflict");
|
|
@@ -1859,7 +2937,9 @@ var IngestionService = class {
|
|
|
1859
2937
|
await this.lock(db, input.itemId);
|
|
1860
2938
|
const item = await this.item(input.itemId, "process", db);
|
|
1861
2939
|
if (item.cancelled) throw new Error("Intake cancelled");
|
|
2940
|
+
await this.assertUnresolved(db, input.itemId);
|
|
1862
2941
|
const [action] = await this.rows(db, "intake_actions", "item_id=? AND id=?", [input.itemId, input.actionId]);
|
|
2942
|
+
if (action && object(action.data).origin === "reviewer-selected") throw new Error("Reviewer-selected action requires review");
|
|
1863
2943
|
const [attempt] = await this.rows(db, "intake_analysis_attempts", "item_id=? AND id=?", [input.itemId, input.attemptId]);
|
|
1864
2944
|
if (!action || !attempt || !["completed", "partial"].includes(String(attempt.state)) || !attempt.output_digest) throw new Error("Invalid proposal evidence");
|
|
1865
2945
|
if (Number(action.proposal_revision) !== input.expectedRevision || [
|
|
@@ -1987,7 +3067,10 @@ var IngestionService = class {
|
|
|
1987
3067
|
asset.sourceUri = String(locator.sourceUri);
|
|
1988
3068
|
await this.options.assets.store.removeFile(asset);
|
|
1989
3069
|
}
|
|
1990
|
-
if (intent.state === "completed")
|
|
3070
|
+
if (intent.state === "completed") {
|
|
3071
|
+
await this.releaseRedactedPrincipal(itemId);
|
|
3072
|
+
continue;
|
|
3073
|
+
}
|
|
1991
3074
|
await this.options.purgeDerived({
|
|
1992
3075
|
tenantId: this.scope.tenantId,
|
|
1993
3076
|
itemId
|
|
@@ -1998,13 +3081,35 @@ var IngestionService = class {
|
|
|
1998
3081
|
const item = await this.item(itemId, "delete", db, true);
|
|
1999
3082
|
for (const table of TABLES.filter((t) => t !== "intake_items" && t !== "intake_deletions")) await db.query(`UPDATE ${table} SET data='{}' WHERE tenant_id=? AND confidential_scope_id=? AND item_id=?`, this.scope.tenantId, this.scope.confidentialScopeId, itemId);
|
|
2000
3083
|
await db.query("UPDATE intake_evidence SET source_uri='',content_hash='',media_type='',byte_length=0 WHERE tenant_id=? AND confidential_scope_id=? AND item_id=?", this.scope.tenantId, this.scope.confidentialScopeId, itemId);
|
|
2001
|
-
await db.query(
|
|
3084
|
+
for (const [table, column] of REDACTED_DIGESTS) await db.query(`UPDATE ${table} SET ${column}=? WHERE tenant_id=? AND confidential_scope_id=? AND item_id=?`, REDACTED, this.scope.tenantId, this.scope.confidentialScopeId, itemId);
|
|
3085
|
+
await db.query("UPDATE intake_items SET data=?,trace_id='',submitter_id='',owner_key='',payload_digest=?,scope_digest=? WHERE tenant_id=? AND confidential_scope_id=? AND id=?", canonical({ capturedCeiling: redactedCeiling(object(object(item.data).capturedCeiling ?? {}), await this.hasUnfinishedActions(db, itemId)) }), REDACTED, REDACTED, this.scope.tenantId, this.scope.confidentialScopeId, itemId);
|
|
2002
3086
|
await db.query("UPDATE intake_deletions SET state='completed' WHERE tenant_id=? AND confidential_scope_id=? AND id=?", this.scope.tenantId, this.scope.confidentialScopeId, intent.id);
|
|
2003
3087
|
});
|
|
2004
3088
|
completed++;
|
|
2005
3089
|
}
|
|
2006
3090
|
return completed;
|
|
2007
3091
|
}
|
|
3092
|
+
async hasUnfinishedActions(db, itemId) {
|
|
3093
|
+
return (await this.rows(db, "intake_actions", `item_id=? AND state IN (${UNFINISHED_ACTION_STATES.map(() => "?").join(",")})`, [itemId, ...UNFINISHED_ACTION_STATES])).length > 0;
|
|
3094
|
+
}
|
|
3095
|
+
/** Later sweeps drop a principal retained for a reconciliation that has
|
|
3096
|
+
* since reached a final state. */
|
|
3097
|
+
async releaseRedactedPrincipal(itemId) {
|
|
3098
|
+
await this.tx(async (db) => {
|
|
3099
|
+
await this.lock(db, itemId);
|
|
3100
|
+
const [item] = await this.rows(db, "intake_items", "id=?", [itemId]);
|
|
3101
|
+
if (!item || item.visibility === "active") return;
|
|
3102
|
+
if (await this.hasUnfinishedActions(db, itemId)) return;
|
|
3103
|
+
const data = object(item.data);
|
|
3104
|
+
const ceiling = object(data.capturedCeiling ?? {});
|
|
3105
|
+
const released = redactedCeiling(ceiling, false);
|
|
3106
|
+
if (canonical(released) === canonical(ceiling)) return;
|
|
3107
|
+
await db.query("UPDATE intake_items SET data=? WHERE tenant_id=? AND confidential_scope_id=? AND id=?", canonical({
|
|
3108
|
+
...data,
|
|
3109
|
+
capturedCeiling: released
|
|
3110
|
+
}), this.scope.tenantId, this.scope.confidentialScopeId, itemId);
|
|
3111
|
+
});
|
|
3112
|
+
}
|
|
2008
3113
|
/** Run host-configured expiry policy and retry interrupted deletion propagation. */
|
|
2009
3114
|
async sweepRetention() {
|
|
2010
3115
|
const expired = await this.rows(this.db, "intake_items", "visibility='active' AND expires_at<=?", [this.now().toISOString()]);
|
|
@@ -2021,6 +3126,6 @@ var IngestionService = class {
|
|
|
2021
3126
|
}
|
|
2022
3127
|
};
|
|
2023
3128
|
//#endregion
|
|
2024
|
-
export { EmailSourceAdapter, GenerationSnapshotStaleError, IngestionService, WatchFolderSourceAdapter, createIntakeReviewHost, createSDKExtractionAdapter, createSourceDeliveryHandler, createVendorWebhookHandler, extractAnalysis, proposeDocumentSplits };
|
|
3129
|
+
export { EmailSourceAdapter, GenerationSnapshotStaleError, GenerationSourceStaleError, HandlerArgumentsInvalidError, ITEM_ID_FILTER_CEILING, ITEM_PAGE_CEILING, ITEM_SCAN_CEILING, IngestionService, IntakeScanLimitError, ProposalsNotConfiguredError, REVIEWER_INSTRUCTION_MAX_LENGTH, ReviewerInstructionInvalidError, ReviewerInstructionStaleError, WatchFolderSourceAdapter, createIntakeReviewHost, createSDKExtractionAdapter, createSourceDeliveryHandler, createVendorWebhookHandler, extractAnalysis, isIntakeId, proposeDocumentSplits, reviewerInstructionView };
|
|
2025
3130
|
|
|
2026
3131
|
//# sourceMappingURL=server.js.map
|