@happyvertical/smrt-ingestion 0.57.19 → 0.57.21
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 +33 -0
- package/PROPOSALS.md +127 -5
- package/README.md +42 -2
- package/REVIEW.md +339 -3
- package/agents/execution.md +15 -0
- package/dist/chunks/{execution-NoDh3pjs.js → execution-9CfjxX8I.js} +110 -22
- package/dist/chunks/execution-9CfjxX8I.js.map +1 -0
- package/dist/chunks/{proposal-errors-VxcTU6Db.js → extraction-CJ-zrbg0.js} +4 -10
- package/dist/chunks/extraction-CJ-zrbg0.js.map +1 -0
- package/dist/chunks/{feedback-CJB900iG.js → feedback-CuUzWKzP.js} +59 -18
- package/dist/chunks/feedback-CuUzWKzP.js.map +1 -0
- package/dist/chunks/reviewer-instruction-Db3jDN9a.js +33 -0
- package/dist/chunks/reviewer-instruction-Db3jDN9a.js.map +1 -0
- package/dist/dto.d.ts +6 -0
- package/dist/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 +43 -1
- package/dist/execution.d.ts.map +1 -1
- package/dist/execution.js +1 -1
- package/dist/feedback-dto.d.ts +6 -0
- package/dist/feedback-dto.d.ts.map +1 -1
- package/dist/feedback.d.ts +7 -0
- package/dist/feedback.d.ts.map +1 -1
- package/dist/manifest.json +19 -1
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +10 -1
- package/dist/models.js.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 +16 -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 +5 -1
- package/dist/proposals.d.ts.map +1 -1
- package/dist/proposals.js +261 -69
- package/dist/proposals.js.map +1 -1
- package/dist/review-dto.d.ts +95 -2
- 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 +171 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +835 -49
- package/dist/server.js.map +1 -1
- package/dist/smrt-knowledge.json +29 -21
- 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 +15 -46
- 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/InboxView.svelte +14 -3
- package/dist/svelte/components/InboxView.svelte.d.ts +8 -1
- 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/ReviewView.svelte +403 -62
- package/dist/svelte/components/ReviewView.svelte.d.ts +2 -0
- package/dist/svelte/components/ReviewView.svelte.d.ts.map +1 -1
- package/dist/svelte/i18n.d.ts +17 -0
- package/dist/svelte/i18n.d.ts.map +1 -1
- package/dist/svelte/i18n.js +17 -0
- package/dist/svelte/index.d.ts +1 -1
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/view-utils.d.ts +2 -0
- package/dist/svelte/view-utils.d.ts.map +1 -1
- package/dist/svelte/view-utils.js +12 -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,28 @@
|
|
|
1
|
+
import { c as GenerationSnapshotStaleError, f as generationSnapshotRead, l as GenerationSourceStaleError, p as listReadGate, u as HandlerArgumentsInvalidError } from "./chunks/execution-9CfjxX8I.js";
|
|
1
2
|
import "./models.js";
|
|
2
|
-
import { n as
|
|
3
|
-
import {
|
|
3
|
+
import { a as assertProposalLimits, i as proposalReadConfiguration, n as extractAnalysis, r as proposeDocumentSplits, t as createSDKExtractionAdapter } from "./chunks/extraction-CJ-zrbg0.js";
|
|
4
|
+
import { i as reviewerInstructionView, n as ReviewerInstructionInvalidError, r as ReviewerInstructionStaleError, t as REVIEWER_INSTRUCTION_MAX_LENGTH } from "./chunks/reviewer-instruction-Db3jDN9a.js";
|
|
4
5
|
import { detectEngine, isEmbeddedDatabase, isTransientDatabaseError, withEmbeddedWriteTransaction } from "@happyvertical/smrt-core";
|
|
5
6
|
import { withTenant } from "@happyvertical/smrt-tenancy";
|
|
6
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
|
|
7
8
|
import { Asset } from "@happyvertical/smrt-assets";
|
|
8
9
|
import { SmrtJobCollection } from "@happyvertical/smrt-jobs";
|
|
9
10
|
import { join } from "node:path";
|
|
10
11
|
import { constants } from "node:fs";
|
|
11
12
|
import { lstat, mkdir, open, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
13
|
+
//#region src/presented-filename.ts
|
|
14
|
+
var UNSAFE = "[\\p{Cc}\\p{Cf}\\p{Cs}\\p{Zl}\\p{Zp}\\p{Default_Ignorable_Code_Point}]";
|
|
15
|
+
var UNSAFE_CHARACTER = new RegExp(UNSAFE, "u");
|
|
16
|
+
var UNSAFE_CHARACTERS = new RegExp(UNSAFE, "gu");
|
|
17
|
+
function isStorableFilename(value) {
|
|
18
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= 512 && !UNSAFE_CHARACTER.test(value);
|
|
19
|
+
}
|
|
20
|
+
function presentedFilename(value) {
|
|
21
|
+
if (typeof value !== "string") return void 0;
|
|
22
|
+
const name = value.replace(UNSAFE_CHARACTERS, "").trim();
|
|
23
|
+
return isStorableFilename(name) ? name : void 0;
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
12
26
|
//#region src/review-analysis.ts
|
|
13
27
|
async function loadReviewAnalysis(service, item, reviews, cursor) {
|
|
14
28
|
if (!["completed", "partial"].includes(item.processingState) || reviews.generationStale) return { reviews };
|
|
@@ -23,6 +37,28 @@ async function loadReviewAnalysis(service, item, reviews, cursor) {
|
|
|
23
37
|
}
|
|
24
38
|
}
|
|
25
39
|
//#endregion
|
|
40
|
+
//#region src/review-instruction-view.ts
|
|
41
|
+
async function loadInstructionView(service, itemId, analysis) {
|
|
42
|
+
let [latest] = await service.listReviewerInstructions(itemId);
|
|
43
|
+
const applied = analysis?.configuration.stage === "interpret" && analysis.configuration.reviewerInstruction ? reviewerInstructionView(analysis.configuration.reviewerInstruction) : null;
|
|
44
|
+
if (!latest && !applied) return void 0;
|
|
45
|
+
const state = await service.getReinterpretationState(itemId);
|
|
46
|
+
if (state.instructionInEffect !== null && state.instructionInEffect !== latest?.id) [latest] = await service.listReviewerInstructions(itemId);
|
|
47
|
+
const current = latest && latest.id === state.instructionInEffect ? latest : null;
|
|
48
|
+
return {
|
|
49
|
+
current,
|
|
50
|
+
applied,
|
|
51
|
+
stale: !!analysis && !!current && current.id !== applied?.id,
|
|
52
|
+
...latest && state.instructionInEffect === null ? { dropped: latest } : {},
|
|
53
|
+
...state.failure ? { failure: state.failure } : {},
|
|
54
|
+
...state.reinterpreting ? { reinterpreting: true } : {},
|
|
55
|
+
...analysis?.configuration.stage !== "interpret" && state.generationAttemptId ? { binding: {
|
|
56
|
+
attemptId: state.generationAttemptId,
|
|
57
|
+
expectedRevision: state.revision
|
|
58
|
+
} } : {}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
26
62
|
//#region src/review-summary.ts
|
|
27
63
|
async function loadReviewSummary(service, item, requestedCursor) {
|
|
28
64
|
let requested;
|
|
@@ -45,7 +81,7 @@ async function loadReviewSummary(service, item, requestedCursor) {
|
|
|
45
81
|
} while (cursor);
|
|
46
82
|
const current = await service.getItem(item.id);
|
|
47
83
|
if (!requested) throw new Error("Review page unavailable");
|
|
48
|
-
const state = total ? succeeded === total ? "completed" : succeeded ? "partially_completed" : deferred ? "deferred" : failed ? "failed" : "waiting" :
|
|
84
|
+
const state = ["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
85
|
return {
|
|
50
86
|
reviews: requested,
|
|
51
87
|
state
|
|
@@ -53,31 +89,139 @@ async function loadReviewSummary(service, item, requestedCursor) {
|
|
|
53
89
|
}
|
|
54
90
|
//#endregion
|
|
55
91
|
//#region src/review-host.ts
|
|
92
|
+
function primaryEvidence(parts) {
|
|
93
|
+
const depth = (part) => {
|
|
94
|
+
let level = 0;
|
|
95
|
+
let parent = part.parentEvidenceId;
|
|
96
|
+
const seen = /* @__PURE__ */ new Set();
|
|
97
|
+
while (parent && !seen.has(parent)) {
|
|
98
|
+
seen.add(parent);
|
|
99
|
+
level++;
|
|
100
|
+
parent = parts.find((p) => p.id === parent)?.parentEvidenceId ?? null;
|
|
101
|
+
}
|
|
102
|
+
return level;
|
|
103
|
+
};
|
|
104
|
+
const ordered = [...parts].sort((a, b) => depth(a) - depth(b) || a.partId.localeCompare(b.partId, "en", { numeric: true }));
|
|
105
|
+
return ordered.find((part) => depth(part) > 0) ?? ordered[0];
|
|
106
|
+
}
|
|
56
107
|
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
|
-
}
|
|
108
|
+
const label = options.label ?? ((item, evidence) => primaryEvidence(evidence)?.filename ?? `Intake ${item.id.slice(0, 8)}`);
|
|
109
|
+
const labelled = (parts) => {
|
|
110
|
+
const primary = primaryEvidence(parts);
|
|
111
|
+
return primary ? [primary, ...parts.filter((part) => part !== primary)] : [...parts];
|
|
112
|
+
};
|
|
113
|
+
const decorateRows = async (entries) => {
|
|
114
|
+
if (!entries.length) return entries;
|
|
115
|
+
let parts;
|
|
116
|
+
try {
|
|
117
|
+
parts = await service.listEvidence(entries.map((entry) => entry.item.id));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (options.rowEvidence) throw error;
|
|
120
|
+
return entries.map((entry) => ({
|
|
121
|
+
...entry,
|
|
122
|
+
label: label(entry.item, [])
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
const named = entries.map((entry) => ({
|
|
126
|
+
...entry,
|
|
127
|
+
label: label(entry.item, labelled(parts.get(entry.item.id) ?? []))
|
|
128
|
+
}));
|
|
129
|
+
if (!options.rowEvidence) return named;
|
|
130
|
+
const rows = [];
|
|
131
|
+
for (const entry of named) {
|
|
132
|
+
const primary = primaryEvidence(parts.get(entry.item.id) ?? []);
|
|
133
|
+
if (primary) rows.push({
|
|
134
|
+
itemId: entry.item.id,
|
|
135
|
+
evidence: primary
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (!rows.length) return named;
|
|
139
|
+
const primaries = new Map(rows.map((row) => [row.itemId, row.evidence]));
|
|
140
|
+
try {
|
|
141
|
+
const views = await options.rowEvidence(rows);
|
|
142
|
+
return named.map((entry) => {
|
|
143
|
+
const primary = primaries.get(entry.item.id);
|
|
144
|
+
if (!primary) return entry;
|
|
145
|
+
const view = views.get(entry.item.id);
|
|
146
|
+
if (!view) return entry;
|
|
147
|
+
if (typeof view.label !== "string") throw new Error("Invalid row evidence");
|
|
148
|
+
const evidence = {
|
|
149
|
+
evidence: primary,
|
|
150
|
+
label: view.label
|
|
151
|
+
};
|
|
152
|
+
if (typeof view.viewUrl === "string") evidence.viewUrl = view.viewUrl;
|
|
153
|
+
if (typeof view.thumbnailUrl === "string") evidence.thumbnailUrl = view.thumbnailUrl;
|
|
154
|
+
return {
|
|
155
|
+
...entry,
|
|
156
|
+
evidence
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
} catch {
|
|
160
|
+
return named;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const assignments = async (itemIds) => {
|
|
164
|
+
if (!itemIds.length) return /* @__PURE__ */ new Map();
|
|
165
|
+
if (options.assignments) return options.assignments(itemIds);
|
|
166
|
+
const read = /* @__PURE__ */ new Map();
|
|
167
|
+
for (const itemId of itemIds) read.set(itemId, await options.assignment(itemId));
|
|
168
|
+
return read;
|
|
68
169
|
};
|
|
69
170
|
return {
|
|
70
171
|
async list(input) {
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
172
|
+
const requested = input.limit ?? 20;
|
|
173
|
+
const limit = Number.isNaN(requested) ? 20 : Math.min(100, Math.max(1, Math.floor(requested)));
|
|
174
|
+
let itemIds;
|
|
175
|
+
if (input.assigneeId && options.assignedItemIds) {
|
|
176
|
+
const assigned = [...new Set((await options.assignedItemIds(input.assigneeId)).filter(isIntakeId))];
|
|
177
|
+
if (assigned.length <= 1e3) itemIds = assigned;
|
|
178
|
+
}
|
|
179
|
+
const items = [];
|
|
180
|
+
let cursor = input.cursor;
|
|
181
|
+
let examined = 0;
|
|
182
|
+
for (;;) {
|
|
183
|
+
let page;
|
|
184
|
+
try {
|
|
185
|
+
page = await service.listItemsPage({
|
|
186
|
+
limit,
|
|
187
|
+
cursor,
|
|
188
|
+
state: input.state,
|
|
189
|
+
itemIds,
|
|
190
|
+
scanLimit: ITEM_SCAN_CEILING - examined
|
|
191
|
+
});
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error instanceof IntakeScanLimitError && cursor !== input.cursor) return {
|
|
194
|
+
items: await decorateRows(items),
|
|
195
|
+
...cursor ? { nextCursor: cursor } : {}
|
|
196
|
+
};
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
examined += page.scanned;
|
|
200
|
+
const read = await assignments(page.items.map(({ item }) => item.id));
|
|
201
|
+
for (const [index, { item, state, cursor: rowCursor }] of page.items.entries()) {
|
|
202
|
+
const assignment = read.get(item.id) ?? {
|
|
203
|
+
assigneeId: null,
|
|
204
|
+
version: 0
|
|
205
|
+
};
|
|
206
|
+
if (input.assigneeId && assignment.assigneeId !== input.assigneeId) continue;
|
|
207
|
+
items.push({
|
|
208
|
+
item,
|
|
209
|
+
label: "",
|
|
210
|
+
state,
|
|
211
|
+
assignment
|
|
212
|
+
});
|
|
213
|
+
if (items.length === limit) return {
|
|
214
|
+
items: await decorateRows(items),
|
|
215
|
+
...page.nextCursor || index < page.items.length - 1 ? { nextCursor: rowCursor } : {}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if (!page.nextCursor) return { items: await decorateRows(items) };
|
|
219
|
+
if (page.items.length < limit || examined >= 1e3) return {
|
|
220
|
+
items: await decorateRows(items),
|
|
221
|
+
nextCursor: page.nextCursor
|
|
222
|
+
};
|
|
223
|
+
cursor = page.nextCursor;
|
|
76
224
|
}
|
|
77
|
-
return {
|
|
78
|
-
items: projected,
|
|
79
|
-
...items.length > 20 ? { nextCursor: items[19].id } : {}
|
|
80
|
-
};
|
|
81
225
|
},
|
|
82
226
|
async load(itemId, cursor) {
|
|
83
227
|
const item = await service.getItem(itemId);
|
|
@@ -89,30 +233,33 @@ function createIntakeReviewHost(service, options) {
|
|
|
89
233
|
evidence: part
|
|
90
234
|
});
|
|
91
235
|
const assignment = await options.assignment(itemId);
|
|
236
|
+
const instruction = await loadInstructionView(service, itemId, analysis);
|
|
92
237
|
await service.getItem(itemId);
|
|
93
238
|
return {
|
|
94
239
|
entry: {
|
|
95
240
|
item,
|
|
96
|
-
label: label(item),
|
|
241
|
+
label: label(item, labelled(evidence.map((view) => view.evidence))),
|
|
97
242
|
state: summary.state,
|
|
98
243
|
assignment
|
|
99
244
|
},
|
|
100
245
|
evidence,
|
|
101
246
|
analysis,
|
|
102
247
|
reviews,
|
|
248
|
+
...instruction ? { instruction } : {},
|
|
103
249
|
availability: "available",
|
|
104
250
|
...analysis?.configuration.stage === "interpret" ? { generation: analysis.result.output.proposals } : {}
|
|
105
251
|
};
|
|
106
252
|
},
|
|
107
253
|
upload: (files, requestId) => options.upload(files, requestId),
|
|
108
254
|
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");
|
|
255
|
+
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
256
|
const analysis = await service.getCompletedAnalysis(input.itemId, input.attemptId);
|
|
111
257
|
const generation = analysis.result.output.proposals;
|
|
112
|
-
|
|
113
|
-
|
|
258
|
+
const suggestion = generation?.suggestions[input.index];
|
|
259
|
+
if (analysis.configuration.stage !== "interpret" || generation?.outcome === "provider_error" || !suggestion || !["ready_for_review", "needs_review"].includes(suggestion.disposition)) throw new Error("Preview unavailable");
|
|
114
260
|
const handler = generation.offered.find(({ handler: handler2 }) => handler2.id === suggestion.handlerId && handler2.version === suggestion.handlerVersion)?.handler;
|
|
115
261
|
if (!handler) throw new Error("Preview unavailable");
|
|
262
|
+
const args = input.args ? structuredClone(input.args) : suggestion.args;
|
|
116
263
|
const intentionKey = `ui:${input.index}`;
|
|
117
264
|
const actionId = handler.kind === "plan" ? void 0 : await service.createAction(input.itemId, intentionKey, {
|
|
118
265
|
origin: "generated-proposal",
|
|
@@ -129,7 +276,7 @@ function createIntakeReviewHost(service, options) {
|
|
|
129
276
|
expectedRevision = handler.kind === "plan" ? found.plan?.revision ?? found.stalePlan?.revision ?? 0 : found.review.revision;
|
|
130
277
|
if (binding?.attemptId === input.attemptId && binding.handlerId === suggestion.handlerId && binding.handlerVersion === suggestion.handlerVersion && binding.args) {
|
|
131
278
|
const { intakeBindingDigest } = await import("./execution.js");
|
|
132
|
-
if (intakeBindingDigest(binding.args) === intakeBindingDigest(
|
|
279
|
+
if (intakeBindingDigest(binding.args) === intakeBindingDigest(args)) return;
|
|
133
280
|
}
|
|
134
281
|
break;
|
|
135
282
|
}
|
|
@@ -137,6 +284,26 @@ function createIntakeReviewHost(service, options) {
|
|
|
137
284
|
if (cursor && cursors.has(cursor)) throw new Error("Repeated review cursor");
|
|
138
285
|
if (cursor) cursors.add(cursor);
|
|
139
286
|
} while (cursor);
|
|
287
|
+
if (input.args) {
|
|
288
|
+
const shared = {
|
|
289
|
+
itemId: input.itemId,
|
|
290
|
+
attemptId: input.attemptId,
|
|
291
|
+
expectedRevision,
|
|
292
|
+
requestId: input.requestId,
|
|
293
|
+
handlerId: suggestion.handlerId,
|
|
294
|
+
handlerVersion: suggestion.handlerVersion,
|
|
295
|
+
args
|
|
296
|
+
};
|
|
297
|
+
if (handler.kind === "plan") await service.previewPlan({
|
|
298
|
+
...shared,
|
|
299
|
+
planKey: intentionKey
|
|
300
|
+
});
|
|
301
|
+
else if (actionId) await service.previewProposal({
|
|
302
|
+
...shared,
|
|
303
|
+
actionId
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
140
307
|
await service.previewGeneratedProposals({
|
|
141
308
|
itemId: input.itemId,
|
|
142
309
|
attemptId: input.attemptId,
|
|
@@ -167,6 +334,17 @@ function createIntakeReviewHost(service, options) {
|
|
|
167
334
|
const result = await service.reviseLogicalSplit(input);
|
|
168
335
|
await options.onSplit?.(input.itemId, result.revision);
|
|
169
336
|
},
|
|
337
|
+
async instruct(input) {
|
|
338
|
+
let result;
|
|
339
|
+
try {
|
|
340
|
+
result = await service.recordReviewerInstruction(input);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if (error instanceof ReviewerInstructionStaleError) return { stale: true };
|
|
343
|
+
if (error instanceof ReviewerInstructionInvalidError) return { invalid: true };
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
await options.onInstruct?.(input.itemId, result.revision);
|
|
347
|
+
},
|
|
170
348
|
...options.enableFeedback ? { feedback: (input) => service.recordFeedback(input) } : {}
|
|
171
349
|
};
|
|
172
350
|
}
|
|
@@ -190,6 +368,10 @@ function text(value) {
|
|
|
190
368
|
if (typeof value !== "string" || !value || value.length > 1024) throw new SourceInputError("invalid");
|
|
191
369
|
return value;
|
|
192
370
|
}
|
|
371
|
+
function withPresentedFilename(value) {
|
|
372
|
+
const filename = presentedFilename(value);
|
|
373
|
+
return filename ? { filename } : {};
|
|
374
|
+
}
|
|
193
375
|
function timestamp(value) {
|
|
194
376
|
const date = new Date(text(value));
|
|
195
377
|
if (!Number.isFinite(date.getTime())) throw new SourceInputError("invalid");
|
|
@@ -286,7 +468,10 @@ async function deliver(binding, deliveryKey, deliveredAt, parts) {
|
|
|
286
468
|
limits: binding.limits,
|
|
287
469
|
deliveryKey,
|
|
288
470
|
deliveredAt,
|
|
289
|
-
parts
|
|
471
|
+
parts: parts.map(({ filename, ...part }) => ({
|
|
472
|
+
...part,
|
|
473
|
+
...withPresentedFilename(filename)
|
|
474
|
+
}))
|
|
290
475
|
});
|
|
291
476
|
} catch (error) {
|
|
292
477
|
return safeFailure(error);
|
|
@@ -352,6 +537,7 @@ var EmailSourceAdapter = class {
|
|
|
352
537
|
parentPartId: "message",
|
|
353
538
|
mediaType: attachment.mediaType,
|
|
354
539
|
bytes: attachment.bytes,
|
|
540
|
+
filename: attachment.filename,
|
|
355
541
|
sourceReference: {
|
|
356
542
|
owner: "message-attachment",
|
|
357
543
|
id: `${delivery.locator}:${attachment.partId}`,
|
|
@@ -485,11 +671,14 @@ function createSourceDeliveryHandler(options) {
|
|
|
485
671
|
fields[name] = value;
|
|
486
672
|
} else {
|
|
487
673
|
if (name !== "file" || parts.length) throw new SourceInputError("invalid");
|
|
674
|
+
const presented = presentedFilename(value.name);
|
|
675
|
+
const original = presented === "blob" ? void 0 : presented;
|
|
488
676
|
parts.push({
|
|
489
677
|
partId: "file",
|
|
490
678
|
parentPartId: "capture",
|
|
491
679
|
mediaType: value.type,
|
|
492
|
-
bytes: await boundedBytes(value.stream(), binding.limits.maxBytes)
|
|
680
|
+
bytes: await boundedBytes(value.stream(), binding.limits.maxBytes),
|
|
681
|
+
...original ? { filename: original } : {}
|
|
493
682
|
});
|
|
494
683
|
}
|
|
495
684
|
if (parts.length !== 1 || text(fields.captureId) !== key || ![
|
|
@@ -604,6 +793,7 @@ var WatchFolderSourceAdapter = class {
|
|
|
604
793
|
partId: "original",
|
|
605
794
|
mediaType,
|
|
606
795
|
bytes,
|
|
796
|
+
filename: claim.filename,
|
|
607
797
|
sourceReference: {
|
|
608
798
|
owner: "watch-folder",
|
|
609
799
|
id: claim.filename,
|
|
@@ -763,9 +953,65 @@ function bytesHash(value) {
|
|
|
763
953
|
function nonempty(value) {
|
|
764
954
|
if (typeof value !== "string" || !value.trim() || value.length > 512) throw new Error("Invalid intake identifier");
|
|
765
955
|
}
|
|
956
|
+
function filename(value) {
|
|
957
|
+
if (!isStorableFilename(value)) throw new Error("Invalid intake filename");
|
|
958
|
+
}
|
|
766
959
|
function integer(value, max = 2147483647) {
|
|
767
960
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0 || value > max) throw new Error("Invalid intake limit");
|
|
768
961
|
}
|
|
962
|
+
var ITEM_PAGE_DEFAULT = 20;
|
|
963
|
+
var ITEM_PAGE_CEILING = 100;
|
|
964
|
+
var ITEM_SCAN_CEILING = 1e3;
|
|
965
|
+
var ITEM_ID_FILTER_CEILING = 1e3;
|
|
966
|
+
function isIntakeId(value) {
|
|
967
|
+
return typeof value === "string" && uuid.test(value);
|
|
968
|
+
}
|
|
969
|
+
var INBOX_STATES = [
|
|
970
|
+
"processing",
|
|
971
|
+
"unresolved",
|
|
972
|
+
"waiting",
|
|
973
|
+
"failed",
|
|
974
|
+
"deferred",
|
|
975
|
+
"partially_completed",
|
|
976
|
+
"completed"
|
|
977
|
+
];
|
|
978
|
+
function actionCount(where = "") {
|
|
979
|
+
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}` : ""})`;
|
|
980
|
+
}
|
|
981
|
+
var INBOX_STATE_SQL = `CASE 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`;
|
|
982
|
+
var RECEIPT_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
983
|
+
function receiptTime(row) {
|
|
984
|
+
const value = row.created_at;
|
|
985
|
+
if (typeof value === "string") return RECEIPT_TIME.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value ? value : void 0;
|
|
986
|
+
if (value instanceof Date && Number.isFinite(value.getTime()) && (row.receipt_ms === true || Number(row.receipt_ms) === 1)) return value.toISOString();
|
|
987
|
+
}
|
|
988
|
+
var IntakeScanLimitError = class extends Error {
|
|
989
|
+
constructor() {
|
|
990
|
+
super("Intake scan limit");
|
|
991
|
+
this.name = "IntakeScanLimitError";
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
var SEALED_CURSOR_BYTES = 1024;
|
|
995
|
+
var SCAN_KEY_CHARS = 64;
|
|
996
|
+
function samePosition(a, b) {
|
|
997
|
+
return a.key === b.key && a.id === b.id;
|
|
998
|
+
}
|
|
999
|
+
function encodeItemCursor(receivedAt, id) {
|
|
1000
|
+
return Buffer.from(JSON.stringify([receivedAt, id])).toString("base64url");
|
|
1001
|
+
}
|
|
1002
|
+
function decodeItemCursor(cursor) {
|
|
1003
|
+
let parsed;
|
|
1004
|
+
try {
|
|
1005
|
+
parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
1006
|
+
} catch {
|
|
1007
|
+
throw new Error("Invalid item page");
|
|
1008
|
+
}
|
|
1009
|
+
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");
|
|
1010
|
+
return {
|
|
1011
|
+
receivedAt: parsed[0],
|
|
1012
|
+
id: parsed[1]
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
769
1015
|
function persistedDate(value) {
|
|
770
1016
|
return value instanceof Date ? value : new Date(String(value));
|
|
771
1017
|
}
|
|
@@ -783,12 +1029,18 @@ var IngestionService = class {
|
|
|
783
1029
|
}
|
|
784
1030
|
this.db = options.db;
|
|
785
1031
|
this.scope = Object.freeze({ ...options.scope });
|
|
1032
|
+
if (options.cursorKey !== void 0) {
|
|
1033
|
+
const secret = typeof options.cursorKey === "string" ? Buffer.from(options.cursorKey, "utf8") : Buffer.from(options.cursorKey);
|
|
1034
|
+
if (secret.length < 32) throw new Error("Cursor key must be at least 32 bytes");
|
|
1035
|
+
this.cursorKey = createHash("sha256").update("smrt-ingestion:list-cursor:v1").update(secret).digest();
|
|
1036
|
+
}
|
|
786
1037
|
if (!uuid.test(this.scope.tenantId)) throw new Error("Required tenant UUID");
|
|
787
1038
|
nonempty(this.scope.actorId);
|
|
788
1039
|
nonempty(this.scope.confidentialScopeId);
|
|
789
1040
|
const configured = this.db;
|
|
790
1041
|
const engine = detectEngine(this.db.url || configured.config?.url || "", configured.type || configured.config?.type);
|
|
791
1042
|
if (!["sqlite", "postgres"].includes(engine) || !this.db.transaction) throw new Error("Ingestion requires transactional SQLite or PostgreSQL");
|
|
1043
|
+
this.engine = engine;
|
|
792
1044
|
nonempty(options.jobTarget.objectType);
|
|
793
1045
|
nonempty(options.jobTarget.method);
|
|
794
1046
|
this.dispatchTarget = Object.freeze({
|
|
@@ -800,6 +1052,8 @@ var IngestionService = class {
|
|
|
800
1052
|
options;
|
|
801
1053
|
db;
|
|
802
1054
|
scope;
|
|
1055
|
+
engine;
|
|
1056
|
+
cursorKey;
|
|
803
1057
|
dispatchTarget;
|
|
804
1058
|
async execution() {
|
|
805
1059
|
if (!this.options.execution) throw new Error("Intake execution is not configured");
|
|
@@ -860,7 +1114,7 @@ var IngestionService = class {
|
|
|
860
1114
|
return (await this.proposalReader()).preview(input);
|
|
861
1115
|
}
|
|
862
1116
|
async feedback() {
|
|
863
|
-
const { IntakeFeedbackService } = await import("./chunks/feedback-
|
|
1117
|
+
const { IntakeFeedbackService } = await import("./chunks/feedback-CuUzWKzP.js").then((n) => n.r);
|
|
864
1118
|
return new IntakeFeedbackService(this.options);
|
|
865
1119
|
}
|
|
866
1120
|
async recordFeedback(input) {
|
|
@@ -970,6 +1224,7 @@ var IngestionService = class {
|
|
|
970
1224
|
total += part.bytes.byteLength;
|
|
971
1225
|
const hash = bytesHash(part.bytes);
|
|
972
1226
|
if (part.expectedHash && part.expectedHash !== hash) throw new Error("Invalid hash");
|
|
1227
|
+
if (part.filename !== void 0) filename(part.filename);
|
|
973
1228
|
return {
|
|
974
1229
|
...part,
|
|
975
1230
|
bytes: Buffer.from(part.bytes),
|
|
@@ -1057,7 +1312,10 @@ var IngestionService = class {
|
|
|
1057
1312
|
byte_length: part.bytes.byteLength,
|
|
1058
1313
|
media_type: part.mediaType,
|
|
1059
1314
|
source_uri: "",
|
|
1060
|
-
data: canonical({
|
|
1315
|
+
data: canonical({
|
|
1316
|
+
sourceReference: part.sourceReference ?? null,
|
|
1317
|
+
...part.filename === void 0 ? {} : { filename: part.filename }
|
|
1318
|
+
})
|
|
1061
1319
|
});
|
|
1062
1320
|
}
|
|
1063
1321
|
return {
|
|
@@ -1385,15 +1643,286 @@ var IngestionService = class {
|
|
|
1385
1643
|
}
|
|
1386
1644
|
return repaired;
|
|
1387
1645
|
}
|
|
1388
|
-
/**
|
|
1646
|
+
/**
|
|
1647
|
+
* One authorized inbox page, newest receipt first with the id as tiebreak.
|
|
1648
|
+
* Tenant, confidential scope, retention and the id restriction run in the
|
|
1649
|
+
* query; rows are authorized before the `state` filter is applied to the
|
|
1650
|
+
* fetched batch, so every filter is bounded by the scan limit (default
|
|
1651
|
+
* ITEM_SCAN_CEILING). Authority is rechecked at two levels: the host
|
|
1652
|
+
* `authorize('read')` once per distinct captured ceiling, and the execution
|
|
1653
|
+
* read gate once per distinct (item, handler) bound by a scanned row that
|
|
1654
|
+
* passed the first. Without a `cursorKey` every cursor names a row the
|
|
1655
|
+
* actor can read; with one, all cursors are sealed and a budget-stopped
|
|
1656
|
+
* page may resume past rows the actor cannot read. `scanned` is for trusted
|
|
1657
|
+
* host accounting and must not be sent to clients.
|
|
1658
|
+
*/
|
|
1659
|
+
async listItemsPage(input = {}) {
|
|
1660
|
+
const limit = input.limit ?? ITEM_PAGE_DEFAULT;
|
|
1661
|
+
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");
|
|
1662
|
+
if (input.itemIds && !input.itemIds.length) return {
|
|
1663
|
+
items: [],
|
|
1664
|
+
scanned: 0
|
|
1665
|
+
};
|
|
1666
|
+
const start = input.cursor ? this.decodeCursor(input.cursor) : void 0;
|
|
1667
|
+
const scan = await this.scanItems(input, limit, input.scanLimit ?? 1e3, start);
|
|
1668
|
+
const items = scan.items.map(({ item, state, position: position2 }) => ({
|
|
1669
|
+
item,
|
|
1670
|
+
state,
|
|
1671
|
+
cursor: this.encodeCursor(position2)
|
|
1672
|
+
}));
|
|
1673
|
+
if (scan.done) return {
|
|
1674
|
+
items,
|
|
1675
|
+
scanned: scan.scanned
|
|
1676
|
+
};
|
|
1677
|
+
const last = scan.items.at(-1)?.position;
|
|
1678
|
+
if (scan.full && last) return {
|
|
1679
|
+
items,
|
|
1680
|
+
scanned: scan.scanned,
|
|
1681
|
+
nextCursor: this.encodeCursor(last)
|
|
1682
|
+
};
|
|
1683
|
+
const position = this.cursorKey ? scan.scanFrom : scan.lastReadable;
|
|
1684
|
+
if (!position || start && samePosition(position, start)) throw new IntakeScanLimitError();
|
|
1685
|
+
return {
|
|
1686
|
+
items,
|
|
1687
|
+
scanned: scan.scanned,
|
|
1688
|
+
nextCursor: this.encodeCursor(position)
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* The bounded page scan behind `listItemsPage` and `listItems`. It returns
|
|
1693
|
+
* the rows to list with their positions, the exact scan position reached
|
|
1694
|
+
* (`scanFrom`, which may be a row the actor cannot read and must never
|
|
1695
|
+
* leave the service unsealed), and the last row the actor can read
|
|
1696
|
+
* (`lastReadable`, which may not match the `state` filter).
|
|
1697
|
+
*/
|
|
1698
|
+
async scanItems(input, limit, scanCeiling, start) {
|
|
1699
|
+
let scanFrom = start;
|
|
1700
|
+
let lastReadable;
|
|
1701
|
+
const now = this.now().toISOString();
|
|
1702
|
+
const allowed = this.ceilingGate();
|
|
1703
|
+
const handlers = this.handlerGate();
|
|
1704
|
+
const items = [];
|
|
1705
|
+
let examined = 0;
|
|
1706
|
+
const sqlite = this.engine === "sqlite";
|
|
1707
|
+
const pinned = sqlite && !input.itemIds ? " INDEXED BY intake_items_received_idx" : "";
|
|
1708
|
+
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` : "";
|
|
1709
|
+
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";
|
|
1710
|
+
for (;;) {
|
|
1711
|
+
const params = [
|
|
1712
|
+
this.scope.tenantId,
|
|
1713
|
+
this.scope.confidentialScopeId,
|
|
1714
|
+
now
|
|
1715
|
+
];
|
|
1716
|
+
let where = textOnly;
|
|
1717
|
+
if (scanFrom) {
|
|
1718
|
+
where += " AND (i.created_at < ? OR (i.created_at = ? AND i.id < ?))";
|
|
1719
|
+
params.push(scanFrom.key, scanFrom.key, scanFrom.id);
|
|
1720
|
+
}
|
|
1721
|
+
if (input.itemIds) {
|
|
1722
|
+
where += ` AND i.id IN (${input.itemIds.map(() => "?").join(",")})`;
|
|
1723
|
+
params.push(...input.itemIds);
|
|
1724
|
+
}
|
|
1725
|
+
const wanted = limit - items.length;
|
|
1726
|
+
const batch = Math.min(scanCeiling - examined, Math.max(wanted, Math.min(100, examined)));
|
|
1727
|
+
const rows = (await this.db.query(`SELECT i.*, ${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;
|
|
1728
|
+
const more = rows.length > batch;
|
|
1729
|
+
const candidates = rows.slice(0, batch);
|
|
1730
|
+
const passed = /* @__PURE__ */ new Set();
|
|
1731
|
+
for (const row of candidates) {
|
|
1732
|
+
let data;
|
|
1733
|
+
try {
|
|
1734
|
+
data = object(row.data);
|
|
1735
|
+
} catch {
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
if (await allowed(data)) passed.add(row);
|
|
1739
|
+
}
|
|
1740
|
+
await handlers.resolve(candidates.filter((row) => passed.has(row) && Number(row.action_count) > 0));
|
|
1741
|
+
let consumed = 0;
|
|
1742
|
+
for (const row of candidates) {
|
|
1743
|
+
consumed++;
|
|
1744
|
+
examined++;
|
|
1745
|
+
const id = String(row.id);
|
|
1746
|
+
const receivedAt = receiptTime(row);
|
|
1747
|
+
if (receivedAt !== void 0) scanFrom = {
|
|
1748
|
+
key: receivedAt,
|
|
1749
|
+
id
|
|
1750
|
+
};
|
|
1751
|
+
else if (typeof row.receipt_raw === "string") scanFrom = {
|
|
1752
|
+
key: row.receipt_raw,
|
|
1753
|
+
id
|
|
1754
|
+
};
|
|
1755
|
+
if (receivedAt === void 0 || !isIntakeId(id) || !passed.has(row)) continue;
|
|
1756
|
+
if (Number(row.action_count) > 0 && !await handlers.readable(row)) continue;
|
|
1757
|
+
const position = {
|
|
1758
|
+
key: receivedAt,
|
|
1759
|
+
id
|
|
1760
|
+
};
|
|
1761
|
+
lastReadable = position;
|
|
1762
|
+
if (input.state && row.inbox_state !== input.state) continue;
|
|
1763
|
+
items.push({
|
|
1764
|
+
item: this.itemDTO(row),
|
|
1765
|
+
state: String(row.inbox_state),
|
|
1766
|
+
position
|
|
1767
|
+
});
|
|
1768
|
+
if (items.length === limit) break;
|
|
1769
|
+
}
|
|
1770
|
+
const remaining = more || consumed < candidates.length;
|
|
1771
|
+
const full = items.length === limit;
|
|
1772
|
+
if (!remaining || full || examined >= scanCeiling) return {
|
|
1773
|
+
items,
|
|
1774
|
+
scanned: examined,
|
|
1775
|
+
done: !remaining,
|
|
1776
|
+
full,
|
|
1777
|
+
scanFrom,
|
|
1778
|
+
lastReadable
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
/** Associated data that binds a sealed cursor to this authenticated scope. */
|
|
1783
|
+
cursorScope() {
|
|
1784
|
+
return Buffer.from(canonical({
|
|
1785
|
+
tenantId: this.scope.tenantId,
|
|
1786
|
+
confidentialScopeId: this.scope.confidentialScopeId,
|
|
1787
|
+
actorId: this.scope.actorId
|
|
1788
|
+
}));
|
|
1789
|
+
}
|
|
1790
|
+
encodeCursor(position) {
|
|
1791
|
+
if (!this.cursorKey) return encodeItemCursor(position.key, position.id);
|
|
1792
|
+
const body = Buffer.alloc(SEALED_CURSOR_BYTES, 32);
|
|
1793
|
+
const json = Buffer.from(JSON.stringify([position.key, position.id]));
|
|
1794
|
+
if (json.length > SEALED_CURSOR_BYTES) throw new Error("Cursor position too long");
|
|
1795
|
+
json.copy(body);
|
|
1796
|
+
const iv = randomBytes(12);
|
|
1797
|
+
const cipher = createCipheriv("aes-256-gcm", this.cursorKey, iv);
|
|
1798
|
+
cipher.setAAD(this.cursorScope());
|
|
1799
|
+
const sealed = Buffer.concat([cipher.update(body), cipher.final()]);
|
|
1800
|
+
return Buffer.concat([
|
|
1801
|
+
iv,
|
|
1802
|
+
cipher.getAuthTag(),
|
|
1803
|
+
sealed
|
|
1804
|
+
]).toString("base64url");
|
|
1805
|
+
}
|
|
1806
|
+
decodeCursor(cursor) {
|
|
1807
|
+
if (!this.cursorKey) {
|
|
1808
|
+
const { receivedAt, id } = decodeItemCursor(cursor);
|
|
1809
|
+
return {
|
|
1810
|
+
key: receivedAt,
|
|
1811
|
+
id
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
try {
|
|
1815
|
+
const bytes = Buffer.from(cursor, "base64url");
|
|
1816
|
+
if (bytes.length !== 1052) throw new Error("size");
|
|
1817
|
+
const decipher = createDecipheriv("aes-256-gcm", this.cursorKey, bytes.subarray(0, 12));
|
|
1818
|
+
decipher.setAAD(this.cursorScope());
|
|
1819
|
+
decipher.setAuthTag(bytes.subarray(12, 28));
|
|
1820
|
+
const parsed = JSON.parse(Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString("utf8"));
|
|
1821
|
+
if (!Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== "string" || !parsed[0] || typeof parsed[1] !== "string" || !parsed[1]) throw new Error("shape");
|
|
1822
|
+
return {
|
|
1823
|
+
key: parsed[0],
|
|
1824
|
+
id: parsed[1]
|
|
1825
|
+
};
|
|
1826
|
+
} catch {
|
|
1827
|
+
throw new Error("Invalid item page");
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Every authorized active item, newest receipt first.
|
|
1832
|
+
* @deprecated Use `listItemsPage`. Kept so callers of the array-returning
|
|
1833
|
+
* API keep working. It walks every page, so its cost grows with the scope,
|
|
1834
|
+
* including one execution authorization per (item, handler) across the
|
|
1835
|
+
* whole scope. It follows the internal scan position, which never leaves
|
|
1836
|
+
* the service, so runs of rows the actor cannot read never stop it. Items
|
|
1837
|
+
* are gated exactly as on a page.
|
|
1838
|
+
*/
|
|
1389
1839
|
async listItems() {
|
|
1390
|
-
const
|
|
1391
|
-
|
|
1392
|
-
for (
|
|
1393
|
-
await this.
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1840
|
+
const all = [];
|
|
1841
|
+
let from;
|
|
1842
|
+
for (;;) {
|
|
1843
|
+
const scan = await this.scanItems({}, 100, ITEM_SCAN_CEILING, from);
|
|
1844
|
+
all.push(...scan.items.map(({ item }) => item));
|
|
1845
|
+
if (scan.done) return all;
|
|
1846
|
+
if (!scan.scanFrom || from && samePosition(scan.scanFrom, from)) throw new Error("Intake list made no progress");
|
|
1847
|
+
from = scan.scanFrom;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* One read authorization per distinct captured ceiling for a page of rows.
|
|
1852
|
+
* A refusal hides the rows; a malformed ceiling hides its row; a host
|
|
1853
|
+
* callback that throws rejects the page with that error.
|
|
1854
|
+
*/
|
|
1855
|
+
ceilingGate() {
|
|
1856
|
+
const ceilings = /* @__PURE__ */ new Map();
|
|
1857
|
+
return (data) => {
|
|
1858
|
+
let ceiling;
|
|
1859
|
+
try {
|
|
1860
|
+
ceiling = object(data.capturedCeiling ?? {});
|
|
1861
|
+
} catch {
|
|
1862
|
+
return Promise.resolve(false);
|
|
1863
|
+
}
|
|
1864
|
+
const key = canonical(ceiling);
|
|
1865
|
+
let decision = ceilings.get(key);
|
|
1866
|
+
if (!decision) {
|
|
1867
|
+
decision = Promise.resolve(this.options.authorize({
|
|
1868
|
+
scope: this.scope,
|
|
1869
|
+
operation: "read",
|
|
1870
|
+
capturedCeiling: ceiling,
|
|
1871
|
+
db: this.db
|
|
1872
|
+
})).then((granted) => granted === true);
|
|
1873
|
+
ceilings.set(key, decision);
|
|
1874
|
+
}
|
|
1875
|
+
return decision;
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* The handler checks `listReviews` applies per action, batched for a page:
|
|
1880
|
+
* the bindings of the rows' current proposals are projected in SQL
|
|
1881
|
+
* (handler id, version, operation and capability; distinct per item) once
|
|
1882
|
+
* per fetched batch, then the execution list read gate runs once per distinct
|
|
1883
|
+
* (item, handler) of a row that passed the item-level gate. An item whose
|
|
1884
|
+
* bound handler the actor cannot currently read, whose binding drifted
|
|
1885
|
+
* from the configured handler, or whose bindings cannot be resolved, is
|
|
1886
|
+
* not listed. Actions without a proposal carry no handler and are not
|
|
1887
|
+
* gated, as in `listReviews`.
|
|
1888
|
+
*/
|
|
1889
|
+
handlerGate() {
|
|
1890
|
+
const bound = /* @__PURE__ */ new Map();
|
|
1891
|
+
let execution;
|
|
1892
|
+
const service = () => {
|
|
1893
|
+
execution ??= (this.options.execution ? this.execution() : Promise.resolve(void 0)).catch(() => void 0);
|
|
1894
|
+
return execution;
|
|
1895
|
+
};
|
|
1896
|
+
const field = (name, text) => this.engine === "sqlite" ? `json_extract(p.data, '$.binding.${name}')` : `(p.data::jsonb)->'binding'${text ? "->>" : "->"}'${name}'`;
|
|
1897
|
+
const parsed = (value) => {
|
|
1898
|
+
if (typeof value !== "string") return value;
|
|
1899
|
+
try {
|
|
1900
|
+
return JSON.parse(value);
|
|
1901
|
+
} catch {
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
return {
|
|
1906
|
+
resolve: async (rows) => {
|
|
1907
|
+
const pending = rows.filter((row) => !bound.has(String(row.id)));
|
|
1908
|
+
if (!pending.length) return;
|
|
1909
|
+
for (const row of pending) bound.set(String(row.id), []);
|
|
1910
|
+
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;
|
|
1911
|
+
for (const row of found) bound.get(String(row.item_id))?.push({
|
|
1912
|
+
handlerId: row.handler_id == null ? "" : String(row.handler_id),
|
|
1913
|
+
handlerVersion: row.handler_version == null ? "" : String(row.handler_version),
|
|
1914
|
+
operation: parsed(row.operation),
|
|
1915
|
+
capability: parsed(row.capability)
|
|
1916
|
+
});
|
|
1917
|
+
},
|
|
1918
|
+
readable: async (row) => {
|
|
1919
|
+
for (const binding of bound.get(String(row.id)) ?? []) {
|
|
1920
|
+
const gate = await service();
|
|
1921
|
+
if (!gate || !await gate[listReadGate](row, binding)) return false;
|
|
1922
|
+
}
|
|
1923
|
+
return true;
|
|
1924
|
+
}
|
|
1925
|
+
};
|
|
1397
1926
|
}
|
|
1398
1927
|
itemDTO(row) {
|
|
1399
1928
|
return {
|
|
@@ -1410,14 +1939,48 @@ var IngestionService = class {
|
|
|
1410
1939
|
}
|
|
1411
1940
|
async getEvidence(itemId) {
|
|
1412
1941
|
await this.item(itemId, "read");
|
|
1413
|
-
return (await this.rows(this.db, "intake_evidence", "item_id=? AND state='durable'", [itemId])).map((e) => (
|
|
1942
|
+
return (await this.rows(this.db, "intake_evidence", "item_id=? AND state='durable'", [itemId])).map((e) => this.evidenceDTO(e));
|
|
1943
|
+
}
|
|
1944
|
+
/**
|
|
1945
|
+
* Durable evidence for one inbox page in a single query, keyed by item id.
|
|
1946
|
+
* Items that are not active, retained and readable are omitted (no
|
|
1947
|
+
* existence disclosure); the host authorize callback runs once per distinct
|
|
1948
|
+
* captured ceiling rather than once per item, through the same gate as
|
|
1949
|
+
* `listItemsPage`: a refusal or a malformed ceiling hides the item, and a
|
|
1950
|
+
* host callback that throws rejects the read.
|
|
1951
|
+
*/
|
|
1952
|
+
async listEvidence(itemIds) {
|
|
1953
|
+
if (!Array.isArray(itemIds) || itemIds.length > 100 || !itemIds.every(isIntakeId)) throw new Error("Invalid item page");
|
|
1954
|
+
const result = /* @__PURE__ */ new Map();
|
|
1955
|
+
if (!itemIds.length) return result;
|
|
1956
|
+
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;
|
|
1957
|
+
const allowed = this.ceilingGate();
|
|
1958
|
+
for (const row of rows) {
|
|
1959
|
+
let data;
|
|
1960
|
+
try {
|
|
1961
|
+
data = object(row.item_data);
|
|
1962
|
+
} catch {
|
|
1963
|
+
continue;
|
|
1964
|
+
}
|
|
1965
|
+
if (!await allowed(data)) continue;
|
|
1966
|
+
const itemId = String(row.item_id);
|
|
1967
|
+
const parts = result.get(itemId) ?? [];
|
|
1968
|
+
parts.push(this.evidenceDTO(row));
|
|
1969
|
+
result.set(itemId, parts);
|
|
1970
|
+
}
|
|
1971
|
+
return result;
|
|
1972
|
+
}
|
|
1973
|
+
evidenceDTO(e) {
|
|
1974
|
+
const filename2 = presentedFilename(object(e.data).filename);
|
|
1975
|
+
return {
|
|
1414
1976
|
id: String(e.id),
|
|
1415
1977
|
partId: String(e.part_id),
|
|
1416
1978
|
parentEvidenceId: e.parent_evidence_id ? String(e.parent_evidence_id) : null,
|
|
1417
1979
|
mediaType: String(e.media_type),
|
|
1418
1980
|
contentHash: String(e.content_hash),
|
|
1419
|
-
byteLength: Number(e.byte_length)
|
|
1420
|
-
|
|
1981
|
+
byteLength: Number(e.byte_length),
|
|
1982
|
+
...filename2 ? { filename: filename2 } : {}
|
|
1983
|
+
};
|
|
1421
1984
|
}
|
|
1422
1985
|
async readEvidence(itemId, evidenceId) {
|
|
1423
1986
|
await this.item(itemId, "read");
|
|
@@ -1434,6 +1997,7 @@ var IngestionService = class {
|
|
|
1434
1997
|
async analyze(itemId, configuration, requestKey) {
|
|
1435
1998
|
const frozen = structuredClone(configuration);
|
|
1436
1999
|
if (Object.hasOwn(frozen, "humanCorrection") && frozen.stage !== "interpret") throw new Error("Human corrections require authenticated split revision");
|
|
2000
|
+
if (Object.hasOwn(frozen, "reviewerInstruction") && frozen.stage !== "interpret") throw new Error("Reviewer instructions require authenticated instruction revision");
|
|
1437
2001
|
return this.tx((db) => this.appendAnalysis(db, itemId, frozen, requestKey));
|
|
1438
2002
|
}
|
|
1439
2003
|
async appendAnalysis(db, itemId, configuration, requestKey) {
|
|
@@ -1444,7 +2008,8 @@ var IngestionService = class {
|
|
|
1444
2008
|
if (!await this.matchesDispatchRoute(db, item)) throw new Error("Intake dispatch route unavailable");
|
|
1445
2009
|
await this.bindDispatchRoute(db, item);
|
|
1446
2010
|
if (item.receipt_state !== "ready" || item.cancelled) throw new Error("Intake not processable");
|
|
1447
|
-
const
|
|
2011
|
+
const previous = await this.rows(db, "intake_analyses", "item_id=?", [itemId]);
|
|
2012
|
+
const replay = previous.find((a) => object(a.data).requestKey === requestKey);
|
|
1448
2013
|
if (replay) {
|
|
1449
2014
|
if (object(replay.data).configDigest !== configDigest) throw new Error("Analysis request conflict");
|
|
1450
2015
|
return {
|
|
@@ -1452,6 +2017,9 @@ var IngestionService = class {
|
|
|
1452
2017
|
revision: Number(replay.revision)
|
|
1453
2018
|
};
|
|
1454
2019
|
}
|
|
2020
|
+
if (configuration.stage === "interpret") {
|
|
2021
|
+
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();
|
|
2022
|
+
}
|
|
1455
2023
|
const evidence = await this.rows(db, "intake_evidence", "item_id=? AND state='durable'", [itemId]);
|
|
1456
2024
|
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
2025
|
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);
|
|
@@ -1523,9 +2091,22 @@ var IngestionService = class {
|
|
|
1523
2091
|
await (await this.proposalReader())[generationSnapshotRead](frozen.itemId, (work) => this.tx(work), (db) => this.completedAnalysisSnapshot(db, frozen.itemId, frozen.attemptId, false), void 0, true);
|
|
1524
2092
|
return replay;
|
|
1525
2093
|
}
|
|
2094
|
+
await this.point("split:checked");
|
|
1526
2095
|
let result;
|
|
1527
|
-
await (await this.proposalReader())[generationSnapshotRead](frozen.itemId, (work) => this.tx(work), (db) => this.completedAnalysisSnapshot(db, frozen.itemId, frozen.attemptId,
|
|
1528
|
-
|
|
2096
|
+
await (await this.proposalReader())[generationSnapshotRead](frozen.itemId, (work) => this.tx(work), (db) => this.completedAnalysisSnapshot(db, frozen.itemId, frozen.attemptId, false), async (db, current) => {
|
|
2097
|
+
await this.lock(db, frozen.itemId);
|
|
2098
|
+
await this.point("split:locked");
|
|
2099
|
+
const [found] = (await this.rows(db, "intake_analyses", "item_id=?", [frozen.itemId])).filter((row) => object(row.data).requestKey === requestKey);
|
|
2100
|
+
if (found) {
|
|
2101
|
+
if (digest(object(object(found.data).configuration).humanCorrection) !== digest(correction)) throw new Error("Split request conflict");
|
|
2102
|
+
result = {
|
|
2103
|
+
id: String(found.id),
|
|
2104
|
+
revision: Number(found.revision)
|
|
2105
|
+
};
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
const item = await this.item(frozen.itemId, "process", db);
|
|
2109
|
+
if (current.revision !== frozen.expectedRevision || Number(item.analysis_revision) !== frozen.expectedRevision) throw new Error("Stale logical split");
|
|
1529
2110
|
const pin = object(object(current.configuration.proposals).source);
|
|
1530
2111
|
const source = await this.completedAnalysisSnapshot(db, frozen.itemId, String(pin.attemptId), false);
|
|
1531
2112
|
for (const key of [
|
|
@@ -1550,6 +2131,7 @@ var IngestionService = class {
|
|
|
1550
2131
|
}
|
|
1551
2132
|
}
|
|
1552
2133
|
if (!observed.size || pages.length !== observed.size || pages.some((page) => !observed.has(page))) throw new Error("Split must cover observed pages exactly");
|
|
2134
|
+
await this.point("split:guarded");
|
|
1553
2135
|
result = await this.appendAnalysis(db, frozen.itemId, {
|
|
1554
2136
|
...source.configuration,
|
|
1555
2137
|
humanCorrection: correction
|
|
@@ -1558,6 +2140,210 @@ var IngestionService = class {
|
|
|
1558
2140
|
if (!result) throw new Error("Split unavailable");
|
|
1559
2141
|
return result;
|
|
1560
2142
|
}
|
|
2143
|
+
/**
|
|
2144
|
+
* Record an authenticated reviewer instruction as an immutable human correction
|
|
2145
|
+
* and queue a fresh extraction plus interpretation that carries it as trusted
|
|
2146
|
+
* guidance. The text steers the model only; it never changes the catalog,
|
|
2147
|
+
* candidates, schemas, permissions or automation eligibility.
|
|
2148
|
+
*/
|
|
2149
|
+
async recordReviewerInstruction(input) {
|
|
2150
|
+
const frozen = structuredClone(input);
|
|
2151
|
+
nonempty(frozen.requestId);
|
|
2152
|
+
nonempty(frozen.attemptId);
|
|
2153
|
+
integer(frozen.expectedRevision);
|
|
2154
|
+
if (typeof frozen.text !== "string" || !frozen.text.trim() || frozen.text.length > 4e3) throw new ReviewerInstructionInvalidError();
|
|
2155
|
+
const requestKey = `instruction:${frozen.requestId}`;
|
|
2156
|
+
const intent = {
|
|
2157
|
+
kind: "reviewer_instruction",
|
|
2158
|
+
itemId: frozen.itemId,
|
|
2159
|
+
attemptId: frozen.attemptId,
|
|
2160
|
+
expectedRevision: frozen.expectedRevision,
|
|
2161
|
+
requestId: frozen.requestId,
|
|
2162
|
+
actorId: this.scope.actorId,
|
|
2163
|
+
text: frozen.text
|
|
2164
|
+
};
|
|
2165
|
+
const replayIntent = (record) => {
|
|
2166
|
+
const { id: _id, recordedAt: _recordedAt, supersedesId: _supersedesId, digest: _digest, ...rest } = record;
|
|
2167
|
+
return rest;
|
|
2168
|
+
};
|
|
2169
|
+
const replay = await this.tx(async (db) => {
|
|
2170
|
+
await this.lock(db, frozen.itemId);
|
|
2171
|
+
await this.item(frozen.itemId, "process", db);
|
|
2172
|
+
const found = (await this.rows(db, "intake_analyses", "item_id=?", [frozen.itemId])).find((row) => object(row.data).requestKey === requestKey);
|
|
2173
|
+
if (!found) return null;
|
|
2174
|
+
const record = object(object(object(found.data).configuration).reviewerInstruction);
|
|
2175
|
+
if (digest(replayIntent(record)) !== digest(intent)) throw new Error("Reviewer instruction request conflict");
|
|
2176
|
+
return {
|
|
2177
|
+
id: String(found.id),
|
|
2178
|
+
revision: Number(found.revision),
|
|
2179
|
+
instruction: reviewerInstructionView(record)
|
|
2180
|
+
};
|
|
2181
|
+
});
|
|
2182
|
+
if (replay) {
|
|
2183
|
+
await (await this.proposalReader())[generationSnapshotRead](frozen.itemId, (work) => this.tx(work), (db) => this.completedAnalysisSnapshot(db, frozen.itemId, frozen.attemptId, false), void 0, true);
|
|
2184
|
+
return replay;
|
|
2185
|
+
}
|
|
2186
|
+
await this.point("instruction:checked");
|
|
2187
|
+
let result;
|
|
2188
|
+
await (await this.proposalReader())[generationSnapshotRead](frozen.itemId, (work) => this.tx(work), (db) => this.completedAnalysisSnapshot(db, frozen.itemId, frozen.attemptId, false), async (db, current) => {
|
|
2189
|
+
await this.lock(db, frozen.itemId);
|
|
2190
|
+
await this.point("instruction:locked");
|
|
2191
|
+
const [found] = (await this.rows(db, "intake_analyses", "item_id=?", [frozen.itemId])).filter((row) => object(row.data).requestKey === requestKey);
|
|
2192
|
+
if (found) {
|
|
2193
|
+
const record2 = object(object(object(found.data).configuration).reviewerInstruction);
|
|
2194
|
+
if (digest(replayIntent(record2)) !== digest(intent)) throw new Error("Reviewer instruction request conflict");
|
|
2195
|
+
result = {
|
|
2196
|
+
id: String(found.id),
|
|
2197
|
+
revision: Number(found.revision),
|
|
2198
|
+
instruction: reviewerInstructionView(record2)
|
|
2199
|
+
};
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
const item = await this.item(frozen.itemId, "process", db);
|
|
2203
|
+
if (Number(item.analysis_revision) !== frozen.expectedRevision) throw new ReviewerInstructionStaleError();
|
|
2204
|
+
let carriedConfiguration;
|
|
2205
|
+
if (current.revision !== frozen.expectedRevision) {
|
|
2206
|
+
const later = await this.laterWork(db, item, current.revision);
|
|
2207
|
+
if (later.phase === "in_flight" || later.phase === "superseded") throw new ReviewerInstructionStaleError();
|
|
2208
|
+
carriedConfiguration = later.configuration;
|
|
2209
|
+
}
|
|
2210
|
+
const pin = object(object(current.configuration.proposals).source);
|
|
2211
|
+
const source = await this.completedAnalysisSnapshot(db, frozen.itemId, String(pin.attemptId), false);
|
|
2212
|
+
for (const key of [
|
|
2213
|
+
"attemptId",
|
|
2214
|
+
"revision",
|
|
2215
|
+
"inputDigest",
|
|
2216
|
+
"outputDigest",
|
|
2217
|
+
"evidenceDigest"
|
|
2218
|
+
]) if (pin[key] !== source[key]) throw new Error("Instruction source changed");
|
|
2219
|
+
const [previous] = await this.recordedInstructions(db, frozen.itemId);
|
|
2220
|
+
const body = {
|
|
2221
|
+
...intent,
|
|
2222
|
+
id: randomUUID(),
|
|
2223
|
+
recordedAt: date(this.now()),
|
|
2224
|
+
supersedesId: previous?.id ?? null
|
|
2225
|
+
};
|
|
2226
|
+
const record = {
|
|
2227
|
+
...body,
|
|
2228
|
+
digest: digest(body)
|
|
2229
|
+
};
|
|
2230
|
+
await this.point("instruction:guarded");
|
|
2231
|
+
result = {
|
|
2232
|
+
...await this.appendAnalysis(db, frozen.itemId, {
|
|
2233
|
+
...carriedConfiguration ?? source.configuration,
|
|
2234
|
+
reviewerInstruction: record
|
|
2235
|
+
}, requestKey),
|
|
2236
|
+
instruction: reviewerInstructionView(record)
|
|
2237
|
+
};
|
|
2238
|
+
}, true);
|
|
2239
|
+
if (!result) throw new Error("Reviewer instruction unavailable");
|
|
2240
|
+
return result;
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* How the revisions after an anchor generation relate to it. Shared by the
|
|
2244
|
+
* retry guard and getReinterpretationState so they cannot disagree.
|
|
2245
|
+
* A completed extraction with no interpretation appended is in flight until
|
|
2246
|
+
* its attempt's completion plus the item's analysis lease duration
|
|
2247
|
+
* (`limits.leaseMs`, the same lease claimAnalysis grants); after that it is
|
|
2248
|
+
* treated as a reinterpretation that did not start.
|
|
2249
|
+
*/
|
|
2250
|
+
async laterWork(db, item, anchorRevision) {
|
|
2251
|
+
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));
|
|
2252
|
+
const stage = (row) => object(object(row.data).configuration).stage;
|
|
2253
|
+
const newestExtraction = later.find((row) => stage(row) !== "interpret");
|
|
2254
|
+
const configuration = newestExtraction ? object(object(newestExtraction.data).configuration) : void 0;
|
|
2255
|
+
const [latest] = later;
|
|
2256
|
+
if (!latest) return {
|
|
2257
|
+
phase: "none",
|
|
2258
|
+
failure: null,
|
|
2259
|
+
configuration
|
|
2260
|
+
};
|
|
2261
|
+
if (later.some((row) => ["completed", "partial"].includes(String(row.state)) && stage(row) === "interpret")) return {
|
|
2262
|
+
phase: "superseded",
|
|
2263
|
+
failure: null,
|
|
2264
|
+
configuration
|
|
2265
|
+
};
|
|
2266
|
+
if (later.some((row) => ["queued", "running"].includes(String(row.state)))) return {
|
|
2267
|
+
phase: "in_flight",
|
|
2268
|
+
failure: null,
|
|
2269
|
+
configuration
|
|
2270
|
+
};
|
|
2271
|
+
const [attempt] = latest.current_attempt_id ? await this.rows(db, "intake_analysis_attempts", "id=? AND item_id=?", [latest.current_attempt_id, String(item.id)]) : [];
|
|
2272
|
+
if (["completed", "partial"].includes(String(latest.state))) {
|
|
2273
|
+
const completedAt = attempt ? object(attempt.data).completedAt : void 0;
|
|
2274
|
+
const leaseMs = Number(object(object(item.data).limits).leaseMs);
|
|
2275
|
+
const started = typeof completedAt === "string" ? persistedDate(completedAt).getTime() : NaN;
|
|
2276
|
+
const deadline = Number.isFinite(started) && Number.isFinite(leaseMs) ? started + leaseMs : Number.NEGATIVE_INFINITY;
|
|
2277
|
+
return this.now().getTime() <= deadline ? {
|
|
2278
|
+
phase: "in_flight",
|
|
2279
|
+
failure: null,
|
|
2280
|
+
configuration
|
|
2281
|
+
} : {
|
|
2282
|
+
phase: "failed",
|
|
2283
|
+
failure: "not_started",
|
|
2284
|
+
configuration
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
const safe = attempt?.safe_error ? String(attempt.safe_error) : "";
|
|
2288
|
+
return {
|
|
2289
|
+
phase: "failed",
|
|
2290
|
+
failure: failures.has(safe) ? safe : null,
|
|
2291
|
+
configuration
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
/** Pollable reinterpretation state: current revision, safe failure and the retry anchor. */
|
|
2295
|
+
async getReinterpretationState(itemId) {
|
|
2296
|
+
const item = await this.item(itemId, "read");
|
|
2297
|
+
const analyses = (await this.rows(this.db, "intake_analyses", "item_id=?", [itemId])).sort((a, b) => Number(b.revision) - Number(a.revision));
|
|
2298
|
+
const generation = analyses.find((row) => ["completed", "partial"].includes(String(row.state)) && row.current_attempt_id && object(object(row.data).configuration).stage === "interpret");
|
|
2299
|
+
let failure = null;
|
|
2300
|
+
let reinterpreting = false;
|
|
2301
|
+
if (generation) {
|
|
2302
|
+
const later = await this.laterWork(this.db, item, Number(generation.revision));
|
|
2303
|
+
failure = later.failure;
|
|
2304
|
+
reinterpreting = later.phase === "in_flight";
|
|
2305
|
+
} else {
|
|
2306
|
+
const latest = analyses[0];
|
|
2307
|
+
reinterpreting = ["queued", "running"].includes(String(latest?.state));
|
|
2308
|
+
if (latest && String(latest.state) === "needs_attention" && latest.current_attempt_id) {
|
|
2309
|
+
const [attempt] = await this.rows(this.db, "intake_analysis_attempts", "id=? AND item_id=?", [latest.current_attempt_id, itemId]);
|
|
2310
|
+
if (attempt?.safe_error && failures.has(String(attempt.safe_error))) failure = String(attempt.safe_error);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
const newestExtraction = analyses.find((row) => object(object(row.data).configuration).stage !== "interpret");
|
|
2314
|
+
const carried = newestExtraction ? object(object(newestExtraction.data).configuration).reviewerInstruction : void 0;
|
|
2315
|
+
const instructionInEffect = carried ? reviewerInstructionView(carried).id : null;
|
|
2316
|
+
await this.item(itemId, "read");
|
|
2317
|
+
return {
|
|
2318
|
+
revision: Number(item.analysis_revision),
|
|
2319
|
+
processingState: String(item.processing_state),
|
|
2320
|
+
failure,
|
|
2321
|
+
reinterpreting,
|
|
2322
|
+
instructionInEffect,
|
|
2323
|
+
generationAttemptId: generation ? String(generation.current_attempt_id) : null
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
2326
|
+
/** Immutable instruction history, newest first. History is never deleted. */
|
|
2327
|
+
async listReviewerInstructions(itemId) {
|
|
2328
|
+
await this.item(itemId, "read");
|
|
2329
|
+
const history = await this.recordedInstructions(this.db, itemId);
|
|
2330
|
+
await this.item(itemId, "read");
|
|
2331
|
+
return history;
|
|
2332
|
+
}
|
|
2333
|
+
async recordedInstructions(db, itemId) {
|
|
2334
|
+
const rows = await this.rows(db, "intake_analyses", "item_id=?", [itemId]);
|
|
2335
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2336
|
+
const history = [];
|
|
2337
|
+
for (const row of rows.sort((a, b) => Number(b.revision) - Number(a.revision))) {
|
|
2338
|
+
const configuration = object(object(row.data).configuration);
|
|
2339
|
+
if (configuration.stage === "interpret" || !configuration.reviewerInstruction) continue;
|
|
2340
|
+
const instruction = reviewerInstructionView(configuration.reviewerInstruction);
|
|
2341
|
+
if (seen.has(instruction.id)) continue;
|
|
2342
|
+
seen.add(instruction.id);
|
|
2343
|
+
history.push(instruction);
|
|
2344
|
+
}
|
|
2345
|
+
return history;
|
|
2346
|
+
}
|
|
1561
2347
|
/** Allocate a fenced attempt; active leases cannot be stolen before expiry. */
|
|
1562
2348
|
async claimAnalysis(itemId, revision, workerId) {
|
|
1563
2349
|
integer(revision);
|
|
@@ -1697,7 +2483,7 @@ var IngestionService = class {
|
|
|
1697
2483
|
"outputDigest",
|
|
1698
2484
|
"evidenceDigest"
|
|
1699
2485
|
]) 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) => ({
|
|
2486
|
+
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
2487
|
id: entry.id,
|
|
1702
2488
|
hash: entry.contentHash
|
|
1703
2489
|
}))) !== source.evidenceDigest) throw new Error("Generation evidence changed");
|
|
@@ -2021,6 +2807,6 @@ var IngestionService = class {
|
|
|
2021
2807
|
}
|
|
2022
2808
|
};
|
|
2023
2809
|
//#endregion
|
|
2024
|
-
export { EmailSourceAdapter, GenerationSnapshotStaleError, IngestionService, WatchFolderSourceAdapter, createIntakeReviewHost, createSDKExtractionAdapter, createSourceDeliveryHandler, createVendorWebhookHandler, extractAnalysis, proposeDocumentSplits };
|
|
2810
|
+
export { EmailSourceAdapter, GenerationSnapshotStaleError, GenerationSourceStaleError, HandlerArgumentsInvalidError, ITEM_ID_FILTER_CEILING, ITEM_PAGE_CEILING, ITEM_SCAN_CEILING, IngestionService, IntakeScanLimitError, REVIEWER_INSTRUCTION_MAX_LENGTH, ReviewerInstructionInvalidError, ReviewerInstructionStaleError, WatchFolderSourceAdapter, createIntakeReviewHost, createSDKExtractionAdapter, createSourceDeliveryHandler, createVendorWebhookHandler, extractAnalysis, isIntakeId, proposeDocumentSplits, reviewerInstructionView };
|
|
2025
2811
|
|
|
2026
2812
|
//# sourceMappingURL=server.js.map
|