@openchambery/web 1.19.12 → 1.19.13-beta.2
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/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-mbvX7YlW.js} +1 -1
- package/dist/assets/AssistantView-D5c557Oe.js +2 -0
- package/dist/assets/DiagramView-CjNUrHsU.js +1 -0
- package/dist/assets/FadeInOnReveal-moBbL8Ll.js +1 -0
- package/dist/assets/{Icon-BYM02QyU.js → Icon-CkzzhaLr.js} +1 -1
- package/dist/assets/MarkdownRenderer-BHfbAwVO.js +2 -0
- package/dist/assets/MarkdownRendererImpl-CkBoUibc.js +7 -0
- package/dist/assets/{MarkstreamRendererImpl-BFdJzxia.js → MarkstreamRendererImpl-BrXUOI9g.js} +1 -1
- package/dist/assets/MobileDetailNavigation-aY9-HwBN.js +1 -0
- package/dist/assets/MobileShareBridge-CRt_8DmM.js +4 -0
- package/dist/assets/MobileSurface-CIhtF-CU.js +2 -0
- package/dist/assets/{MultiRunWindow-C3p3lj3U.js → MultiRunWindow-DDq6C8t7.js} +1 -1
- package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
- package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
- package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
- package/dist/assets/{SettingsWindow-8IDuPZA5.js → SettingsWindow-CnYfBplB.js} +1 -1
- package/dist/assets/TerminalView-D5CQFVVm.js +8 -0
- package/dist/assets/ToolOutputDialog-VMI7TQFT.js +10 -0
- package/dist/assets/{appThemeRegistry-B87OLIgc.js → appThemeRegistry-UIrP0-5h.js} +1 -1
- package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
- package/dist/assets/{button-C0cth9tl.js → button-xx1UOBA5.js} +1 -1
- package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-CUasgPiF.js} +1 -1
- package/dist/assets/{collapsible-DFT9cE80.js → collapsible-CACHBRYZ.js} +1 -1
- package/dist/assets/{context-menu-CAp74NfV.js → context-menu-CYbm4wBZ.js} +1 -1
- package/dist/assets/desktopHosts-DILvrAok.js +1 -0
- package/dist/assets/desktopRelayRestore-t8asCr3m.js +1 -0
- package/dist/assets/{diagram-BryzMuta.js → diagram-u0BC9uuY.js} +1 -1
- package/dist/assets/{dialog-CgbV5ljg.js → dialog-BwQ3rY_x.js} +1 -1
- package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
- package/dist/assets/{es-BHqnSjoZ.js → es-CRjOwvYM.js} +1 -1
- package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
- package/dist/assets/{fr-BfrlJY9i.js → fr-D9taEN9L.js} +1 -1
- package/dist/assets/{gitApi-tTtHQVA3.js → gitApi-Bu4W3bgD.js} +1 -1
- package/dist/assets/imageSource-BlAJJUEI.js +1 -0
- package/dist/assets/{input-DFLpqjGR.js → input-CI730njs.js} +1 -1
- package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
- package/dist/assets/{ja-DP-7qt-x.js → ja-CYQ5aoWp.js} +1 -1
- package/dist/assets/{ko-E2B5vGQP.js → ko-BwASV0Q0.js} +1 -1
- package/dist/assets/main-CqcB6tKc.js +169 -0
- package/dist/assets/main-DG-k0j8A.js +2 -0
- package/dist/assets/markdown-worker-CptoICmq.js +2 -0
- package/dist/assets/markdownHeightCache-DEjGKLv8.js +3 -0
- package/dist/assets/markdownTheme-DOfGg-A7.js +1 -0
- package/dist/assets/miniChat-BQtYQrsh.js +2 -0
- package/dist/assets/mobile-8iyqtXkr.js +2 -0
- package/dist/assets/{multirun-B0V3hdnD.js → multirun-CG_FCb_Z.js} +1 -1
- package/dist/assets/persistence-YbQKzHUQ.js +3 -0
- package/dist/assets/{pl-r8X5gRM5.js → pl-DUI1vPil.js} +1 -1
- package/dist/assets/platform-DzsGqlGW.js +2 -0
- package/dist/assets/projectMeta-DMpMDWE1.js +3 -0
- package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-CZQFJllP.js} +1 -1
- package/dist/assets/relay-image-stream-DM7atqJs.js +2 -0
- package/dist/assets/renderElectronMiniChatApp-DBzRdJl8.js +2 -0
- package/dist/assets/renderMobileApp-CIseqCyn.js +28 -0
- package/dist/assets/runtime-auth-D66yq6ie.js +1 -0
- package/dist/assets/runtime-fetch-zIalUnbb.js +1 -0
- package/dist/assets/runtime-switch-LvWDU-SL.js +1 -0
- package/dist/assets/runtimeConfig-D8nJYB-e.js +2 -0
- package/dist/assets/runtimeEndpointReset-CSub_ecV.js +147 -0
- package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
- package/dist/assets/{sessionLookup-B6P_aCun.js → sessionLookup-BiBD-4wC.js} +1 -1
- package/dist/assets/sortable-tabs-strip-Dom6Sthu.js +1 -0
- package/dist/assets/src-AA-5nXsF.css +2 -0
- package/dist/assets/{store-BDFdnk6l.js → store-CSnH_QXM.js} +2 -2
- package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
- package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BfNtUdJE.js} +1 -1
- package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-DPxumadF.js} +1 -1
- package/dist/assets/{uk-Di0vl9V-.js → uk-C1FYZlmA.js} +1 -1
- package/dist/assets/{url-Pk6tQ1EZ.js → url-CVhsI9kR.js} +1 -1
- package/dist/assets/{useAppFontEffects-Dmes1chr.js → useAppFontEffects-qHt5uaFN.js} +32 -32
- package/dist/assets/useAssistantUIStore-CLPCNoj_.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-BipyIXiK.js +1 -0
- package/dist/assets/{useEffectiveDirectory-CU2ChP0J.js → useEffectiveDirectory-DSNCCbCt.js} +1 -1
- package/dist/assets/useFontPreferences-CB-cGo3I.js +1 -0
- package/dist/assets/useMobileNavigationStore-C1NJGAik.js +413 -0
- package/dist/assets/useSessionAutoCleanup-Dcn6Unvd.js +2 -0
- package/dist/assets/useUIStore-CxOoCv7B.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-CzpT4M7V.js +7 -0
- package/dist/assets/{utils-BiK3tBGM.js → utils-BHTVlVov.js} +1 -1
- package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-C96qP1ip.js} +1 -1
- package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-BsRk1Rbb.js} +1 -1
- package/dist/index.html +12 -9
- package/dist/mini-chat.html +12 -9
- package/dist/mobile.html +12 -9
- package/package.json +1 -1
- package/server/index.js +19 -0
- package/server/lib/assistant-delivery-parts.js +19 -1
- package/server/lib/assistants/DOCUMENTATION.md +56 -11
- package/server/lib/assistants/assign.js +455 -30
- package/server/lib/assistants/assign.test.js +351 -0
- package/server/lib/assistants/cards.js +16 -2
- package/server/lib/assistants/cards.test.js +16 -1
- package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
- package/server/lib/assistants/contact-attachments.http.test.js +436 -0
- package/server/lib/assistants/contact-attachments.js +814 -0
- package/server/lib/assistants/contact-attachments.p1.test.js +307 -0
- package/server/lib/assistants/contact-attachments.test.js +249 -0
- package/server/lib/assistants/contact-store.js +552 -19
- package/server/lib/assistants/contact-store.test.js +415 -1
- package/server/lib/assistants/contact-tools.js +114 -14
- package/server/lib/assistants/contact-tools.test.js +135 -6
- package/server/lib/assistants/contracts.js +42 -2
- package/server/lib/assistants/harness.js +4 -1
- package/server/lib/assistants/harness.test.js +124 -0
- package/server/lib/assistants/routes.js +46 -1
- package/server/lib/assistants/service.js +569 -84
- package/server/lib/assistants/service.test.js +936 -37
- package/server/lib/fs/prompt-attachment-store.js +663 -0
- package/server/lib/fs/prompt-attachment-store.test.js +263 -0
- package/server/lib/fs/routes.js +28 -72
- package/server/lib/fs/routes.test.js +15 -30
- package/server/lib/llm/catalog.js +91 -16
- package/server/lib/llm/catalog.test.js +152 -11
- package/server/lib/notifications/APNS.md +20 -4
- package/server/lib/notifications/DOCUMENTATION.md +5 -0
- package/server/lib/notifications/apns-runtime.js +136 -18
- package/server/lib/notifications/apns-runtime.test.js +128 -0
- package/server/lib/notifications/apns-titles.js +21 -1
- package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
- package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
- package/server/lib/notifications/runtime.js +93 -2
- package/server/lib/notifications/runtime.test.js +167 -4
- package/server/lib/opencode/core-routes.js +2 -1
- package/server/lib/opencode/settings-helpers.js +12 -0
- package/server/lib/opencode/settings-runtime.js +16 -0
- package/server/lib/opencode/settings-runtime.test.js +21 -0
- package/server/lib/scheduled-tasks/runtime.js +21 -0
- package/server/lib/scheduled-tasks/runtime.test.js +24 -0
- package/dist/assets/AssistantView-DxIghtun.js +0 -2
- package/dist/assets/DiagramView-BeUgp_Y0.js +0 -1
- package/dist/assets/FadeInOnReveal-CTlNE4Hg.js +0 -1
- package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +0 -2
- package/dist/assets/MarkdownRendererImpl-DB4Q3kGa.js +0 -7
- package/dist/assets/MobileDetailNavigation-B_RwWOwv.js +0 -1
- package/dist/assets/MobileShareBridge-UiOLx0-2.js +0 -4
- package/dist/assets/MobileSurface-CNChj_dX.js +0 -2
- package/dist/assets/OnboardingScreen-DS2nugLX.js +0 -1
- package/dist/assets/SettingsView-BXfKsz0J.js +0 -25
- package/dist/assets/TerminalView-BiKCueth.js +0 -8
- package/dist/assets/ToolOutputDialog-BC2F_Fdl.js +0 -10
- package/dist/assets/desktopHosts-Dc2PS66M.js +0 -1
- package/dist/assets/desktopRelayRestore-T9y0orqa.js +0 -1
- package/dist/assets/fileQueries-BY-9OWzp.js +0 -1
- package/dist/assets/insertionBoundaries-CCQpZ5zn.js +0 -4
- package/dist/assets/main-C_PUoT3E.js +0 -169
- package/dist/assets/main-Dkg31fRY.js +0 -2
- package/dist/assets/markdown-worker-CTsrtpPh.js +0 -2
- package/dist/assets/markdownHeightCache-CYDlisxr.js +0 -3
- package/dist/assets/markdownTheme-DyJ4Gfcj.js +0 -2
- package/dist/assets/miniChat-C9QMj8T8.js +0 -2
- package/dist/assets/mobile-Z_X7CbzB.js +0 -2
- package/dist/assets/persistence-DdUac14I.js +0 -3
- package/dist/assets/platform-B4KKLnZ5.js +0 -2
- package/dist/assets/projectMeta-DRW9MQ4z.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-Dn1Su1dd.js +0 -2
- package/dist/assets/renderMobileApp-BG1JJt1Q.js +0 -28
- package/dist/assets/runtimeConfig-C6matIEB.js +0 -2
- package/dist/assets/runtimeEndpointReset-kgwmUJDb.js +0 -147
- package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +0 -1
- package/dist/assets/src-B-P5qIBc.css +0 -2
- package/dist/assets/terminalApi-947l7Ra3.js +0 -1
- package/dist/assets/useAssistantUIStore-me6s63b2.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DuZ6URPR.js +0 -1
- package/dist/assets/useFontPreferences-BCOBlaAd.js +0 -1
- package/dist/assets/useMobileNavigationStore-C8_J-qTp.js +0 -413
- package/dist/assets/useSessionAutoCleanup-FOQeu1_T.js +0 -1
- package/dist/assets/useUIStore-CuaA8-Hp.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-GBqKnki1.js +0 -7
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assistant contact attachment ownership + descriptors.
|
|
3
|
+
* Bytes live in shared content-addressed prompt-attachments store.
|
|
4
|
+
* DB only stores controlled relative paths + opaque attachment IDs.
|
|
5
|
+
*/
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import {
|
|
8
|
+
MAX_PROMPT_ATTACHMENT_BYTES,
|
|
9
|
+
PROMPT_ATTACHMENT_ID,
|
|
10
|
+
PROMPT_ATTACHMENT_MIME,
|
|
11
|
+
PROMPT_ATTACHMENT_SHA256,
|
|
12
|
+
absoluteObjectPath,
|
|
13
|
+
collectRequestBytes,
|
|
14
|
+
decodeDataUrlStrict,
|
|
15
|
+
isSafeInlineImageMime,
|
|
16
|
+
promptAttachmentFileExistsSync,
|
|
17
|
+
readPromptAttachmentBytes,
|
|
18
|
+
relativeObjectPath,
|
|
19
|
+
sha256Hex,
|
|
20
|
+
storePromptAttachmentBytes,
|
|
21
|
+
} from '../fs/prompt-attachment-store.js';
|
|
22
|
+
import { getContactGeneration } from './contact-store.js';
|
|
23
|
+
|
|
24
|
+
export const CONTACT_ATTACHMENT_MAX_BYTES = MAX_PROMPT_ATTACHMENT_BYTES;
|
|
25
|
+
/** Total inbound file bytes per contact send (sum of attachments). */
|
|
26
|
+
export const CONTACT_ATTACHMENT_TURN_BUDGET_BYTES = 50 * 1024 * 1024;
|
|
27
|
+
export const CONTACT_ATTACHMENT_MIGRATE_BATCH = 20;
|
|
28
|
+
export const CONTACT_ATTACHMENT_MIGRATE_BATCH_BYTES = 50 * 1024 * 1024;
|
|
29
|
+
/**
|
|
30
|
+
* Historical legacy data-URL decode ceiling.
|
|
31
|
+
* ~70MiB base64-encoded ≈ 52.5MiB decoded; 56MiB leaves a small safety margin.
|
|
32
|
+
* Over-cap rows keep old part_json and are reported in migration failures.
|
|
33
|
+
*/
|
|
34
|
+
export const CONTACT_ATTACHMENT_HISTORICAL_MAX_BYTES = 56 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
const ATTACHMENT_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
|
|
37
|
+
|
|
38
|
+
const fail = (code, message) => {
|
|
39
|
+
const error = new Error(typeof message === 'string' && message.trim() ? message.trim() : code);
|
|
40
|
+
error.code = code;
|
|
41
|
+
throw error;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const CONTACT_ATTACHMENT_SCHEMA_SQL = `
|
|
45
|
+
CREATE TABLE IF NOT EXISTS assistant_contact_attachment (
|
|
46
|
+
attachment_id TEXT PRIMARY KEY,
|
|
47
|
+
assistant_id TEXT NOT NULL,
|
|
48
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
49
|
+
sha256 TEXT NOT NULL,
|
|
50
|
+
size INTEGER NOT NULL,
|
|
51
|
+
mime TEXT NOT NULL,
|
|
52
|
+
filename TEXT,
|
|
53
|
+
relative_path TEXT NOT NULL,
|
|
54
|
+
upload_id TEXT,
|
|
55
|
+
created_at INTEGER NOT NULL
|
|
56
|
+
);
|
|
57
|
+
CREATE UNIQUE INDEX IF NOT EXISTS assistant_contact_attachment_upload
|
|
58
|
+
ON assistant_contact_attachment(assistant_id, upload_id) WHERE upload_id IS NOT NULL;
|
|
59
|
+
CREATE INDEX IF NOT EXISTS assistant_contact_attachment_assistant
|
|
60
|
+
ON assistant_contact_attachment(assistant_id, generation, attachment_id);
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
export function ensureContactAttachmentSchema(db) {
|
|
64
|
+
// Table first (always).
|
|
65
|
+
db.exec(`
|
|
66
|
+
CREATE TABLE IF NOT EXISTS assistant_contact_attachment (
|
|
67
|
+
attachment_id TEXT PRIMARY KEY,
|
|
68
|
+
assistant_id TEXT NOT NULL,
|
|
69
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
70
|
+
sha256 TEXT NOT NULL,
|
|
71
|
+
size INTEGER NOT NULL,
|
|
72
|
+
mime TEXT NOT NULL,
|
|
73
|
+
filename TEXT,
|
|
74
|
+
relative_path TEXT NOT NULL,
|
|
75
|
+
upload_id TEXT,
|
|
76
|
+
created_at INTEGER NOT NULL
|
|
77
|
+
);
|
|
78
|
+
`);
|
|
79
|
+
db.exec(`
|
|
80
|
+
CREATE INDEX IF NOT EXISTS assistant_contact_attachment_assistant
|
|
81
|
+
ON assistant_contact_attachment(assistant_id, generation, attachment_id);
|
|
82
|
+
`);
|
|
83
|
+
// Partial unique index — if unsupported, fall back to full unique on (assistant_id, upload_id)
|
|
84
|
+
// where upload_id is NOT NULL via application-level checks (putContactAttachment).
|
|
85
|
+
try {
|
|
86
|
+
db.exec(`
|
|
87
|
+
CREATE UNIQUE INDEX IF NOT EXISTS assistant_contact_attachment_upload
|
|
88
|
+
ON assistant_contact_attachment(assistant_id, upload_id) WHERE upload_id IS NOT NULL;
|
|
89
|
+
`);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
try {
|
|
92
|
+
db.exec(`
|
|
93
|
+
CREATE UNIQUE INDEX IF NOT EXISTS assistant_contact_attachment_upload_full
|
|
94
|
+
ON assistant_contact_attachment(assistant_id, upload_id);
|
|
95
|
+
`);
|
|
96
|
+
} catch (fallbackError) {
|
|
97
|
+
// Surface once — do not swallow silently.
|
|
98
|
+
console.warn(
|
|
99
|
+
'[assistants] contact attachment unique index unavailable:',
|
|
100
|
+
fallbackError?.message || error?.message || fallbackError,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const headerValue = (value) => {
|
|
107
|
+
if (Array.isArray(value)) return value[0];
|
|
108
|
+
return typeof value === 'string' ? value : undefined;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export function parseAttachmentFilenameHeader(raw) {
|
|
112
|
+
if (typeof raw !== 'string' || !raw.trim()) return null;
|
|
113
|
+
try {
|
|
114
|
+
const decoded = decodeURIComponent(raw.trim());
|
|
115
|
+
const cleaned = decoded.replace(/[\u0000-\u001f\u007f]/g, '').trim();
|
|
116
|
+
if (!cleaned || cleaned.length > 512) return null;
|
|
117
|
+
if (cleaned.includes('/') || cleaned.includes('\\') || cleaned.includes('..')) return null;
|
|
118
|
+
return cleaned;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Public descriptor — never includes filesystem paths. */
|
|
125
|
+
export function toContactAttachmentDescriptor(row) {
|
|
126
|
+
if (!row) return null;
|
|
127
|
+
return {
|
|
128
|
+
type: 'file',
|
|
129
|
+
attachmentID: row.attachment_id,
|
|
130
|
+
sha256: row.sha256,
|
|
131
|
+
size: row.size,
|
|
132
|
+
mime: row.mime,
|
|
133
|
+
...(row.filename ? { filename: row.filename } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function getContactAttachmentRow(db, attachmentID) {
|
|
138
|
+
if (typeof attachmentID !== 'string' || !ATTACHMENT_ID.test(attachmentID.trim())) return null;
|
|
139
|
+
return db.prepare(
|
|
140
|
+
'SELECT * FROM assistant_contact_attachment WHERE attachment_id=?',
|
|
141
|
+
).get(attachmentID.trim()) || null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function getContactAttachmentByUploadID(db, assistantID, uploadID) {
|
|
145
|
+
if (typeof uploadID !== 'string' || !PROMPT_ATTACHMENT_ID.test(uploadID.trim())) return null;
|
|
146
|
+
return db.prepare(
|
|
147
|
+
'SELECT * FROM assistant_contact_attachment WHERE assistant_id=? AND upload_id=?',
|
|
148
|
+
).get(assistantID, uploadID.trim()) || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* External GET / admission: assistant match + generation === current live gen.
|
|
153
|
+
*/
|
|
154
|
+
export function assertContactAttachmentReadable(db, { attachmentID, assistantID }) {
|
|
155
|
+
const row = getContactAttachmentRow(db, attachmentID);
|
|
156
|
+
if (!row || row.assistant_id !== assistantID) {
|
|
157
|
+
fail('not_found', 'Attachment not found');
|
|
158
|
+
}
|
|
159
|
+
const current = getContactGeneration(db, assistantID);
|
|
160
|
+
if (row.generation !== current) {
|
|
161
|
+
fail('not_found', 'Attachment not found');
|
|
162
|
+
}
|
|
163
|
+
return row;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Already-admitted execution only: assistant ownership, any generation ≤ current
|
|
168
|
+
* so an in-flight turn can still materialize after a concurrent wipe tagged the
|
|
169
|
+
* attachment under the pre-wipe generation.
|
|
170
|
+
*/
|
|
171
|
+
export function assertContactAttachmentOwned(db, { attachmentID, assistantID }) {
|
|
172
|
+
const row = getContactAttachmentRow(db, attachmentID);
|
|
173
|
+
if (!row || row.assistant_id !== assistantID) {
|
|
174
|
+
fail('not_found', 'Attachment not found');
|
|
175
|
+
}
|
|
176
|
+
const current = getContactGeneration(db, assistantID);
|
|
177
|
+
if (row.generation > current) {
|
|
178
|
+
fail('not_found', 'Attachment not found');
|
|
179
|
+
}
|
|
180
|
+
return row;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Rebuild public descriptor solely from storage row (ignore client metadata). */
|
|
184
|
+
export function canonicalDescriptorFromRow(row) {
|
|
185
|
+
return toContactAttachmentDescriptor(row);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function putContactAttachment({
|
|
189
|
+
db,
|
|
190
|
+
dataDir,
|
|
191
|
+
assistantID,
|
|
192
|
+
uploadID,
|
|
193
|
+
stream,
|
|
194
|
+
headers = {},
|
|
195
|
+
clock = () => Date.now(),
|
|
196
|
+
signal,
|
|
197
|
+
storeBytes = storePromptAttachmentBytes,
|
|
198
|
+
} = {}) {
|
|
199
|
+
if (typeof assistantID !== 'string' || !assistantID) fail('validation_error', 'assistantID required');
|
|
200
|
+
if (typeof uploadID !== 'string' || !PROMPT_ATTACHMENT_ID.test(uploadID.trim())) {
|
|
201
|
+
fail('validation_error', 'uploadID is invalid');
|
|
202
|
+
}
|
|
203
|
+
const upload = uploadID.trim();
|
|
204
|
+
|
|
205
|
+
const existing = getContactAttachmentByUploadID(db, assistantID, upload);
|
|
206
|
+
const expectedSize = Number(
|
|
207
|
+
headerValue(headers['x-content-size'])
|
|
208
|
+
?? headerValue(headers['x-openchamber-content-length'])
|
|
209
|
+
?? headerValue(headers['content-length']),
|
|
210
|
+
);
|
|
211
|
+
const expectedSha256 = (
|
|
212
|
+
headerValue(headers['x-content-sha256'])
|
|
213
|
+
|| headerValue(headers['x-openchamber-sha256'])
|
|
214
|
+
|| ''
|
|
215
|
+
).toLowerCase();
|
|
216
|
+
const mimeRaw = headerValue(headers['content-type'])
|
|
217
|
+
|| headerValue(headers['x-openchamber-mime'])
|
|
218
|
+
|| 'application/octet-stream';
|
|
219
|
+
const mime = mimeRaw.split(';', 1)[0].trim();
|
|
220
|
+
const filename = parseAttachmentFilenameHeader(
|
|
221
|
+
headerValue(headers['x-attachment-filename'])
|
|
222
|
+
|| headerValue(headers['x-openchamber-filename']),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
if (!Number.isSafeInteger(expectedSize) || expectedSize < 0 || expectedSize > CONTACT_ATTACHMENT_MAX_BYTES) {
|
|
226
|
+
fail('validation_error', 'Attachment size is required');
|
|
227
|
+
}
|
|
228
|
+
if (!PROMPT_ATTACHMENT_SHA256.test(expectedSha256)) {
|
|
229
|
+
fail('validation_error', 'Attachment digest is required');
|
|
230
|
+
}
|
|
231
|
+
if (!PROMPT_ATTACHMENT_MIME.test(mime)) {
|
|
232
|
+
fail('validation_error', 'Attachment MIME type is invalid');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (existing) {
|
|
236
|
+
if (existing.sha256 !== expectedSha256 || existing.size !== expectedSize || existing.mime !== mime) {
|
|
237
|
+
fail('idempotency_conflict', 'Upload ID already used with different content');
|
|
238
|
+
}
|
|
239
|
+
// Re-check generation still current after any concurrent reset.
|
|
240
|
+
const live = getContactGeneration(db, assistantID);
|
|
241
|
+
if (existing.generation !== live) {
|
|
242
|
+
fail('contact_generation_conflict', 'Contact generation changed during upload');
|
|
243
|
+
}
|
|
244
|
+
return canonicalDescriptorFromRow(existing);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const genBefore = getContactGeneration(db, assistantID);
|
|
248
|
+
const { buffer, size } = await collectRequestBytes(stream, {
|
|
249
|
+
expectedSize,
|
|
250
|
+
maxBytes: CONTACT_ATTACHMENT_MAX_BYTES,
|
|
251
|
+
signal,
|
|
252
|
+
});
|
|
253
|
+
// After await: reset/delete may have raced — re-check before durable write/DB.
|
|
254
|
+
const genAfterRead = getContactGeneration(db, assistantID);
|
|
255
|
+
if (genAfterRead !== genBefore) {
|
|
256
|
+
fail('contact_generation_conflict', 'Contact generation changed during upload');
|
|
257
|
+
}
|
|
258
|
+
if (getContactAttachmentByUploadID(db, assistantID, upload)) {
|
|
259
|
+
const racedEarly = getContactAttachmentByUploadID(db, assistantID, upload);
|
|
260
|
+
if (racedEarly.sha256 === expectedSha256 && racedEarly.size === expectedSize) {
|
|
261
|
+
return canonicalDescriptorFromRow(racedEarly);
|
|
262
|
+
}
|
|
263
|
+
fail('idempotency_conflict', 'Upload ID already used with different content');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const stored = await storeBytes({
|
|
267
|
+
dataDir,
|
|
268
|
+
buffer,
|
|
269
|
+
expectedSha256,
|
|
270
|
+
mime,
|
|
271
|
+
filename: filename || undefined,
|
|
272
|
+
maxBytes: CONTACT_ATTACHMENT_MAX_BYTES,
|
|
273
|
+
validateImage: true,
|
|
274
|
+
});
|
|
275
|
+
if (Number.isSafeInteger(expectedSize) && stored.size !== expectedSize) {
|
|
276
|
+
fail('attachment_hash_mismatch', 'Stored size does not match declared size');
|
|
277
|
+
}
|
|
278
|
+
if (expectedSha256 && stored.sha256 !== expectedSha256) {
|
|
279
|
+
fail('attachment_hash_mismatch', 'Stored digest does not match declared digest');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const genAfterStore = getContactGeneration(db, assistantID);
|
|
283
|
+
if (genAfterStore !== genBefore) {
|
|
284
|
+
fail('contact_generation_conflict', 'Contact generation changed during upload');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const attachmentID = `att_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
288
|
+
const at = clock();
|
|
289
|
+
try {
|
|
290
|
+
db.prepare(
|
|
291
|
+
`INSERT INTO assistant_contact_attachment(
|
|
292
|
+
attachment_id, assistant_id, generation, sha256, size, mime, filename, relative_path, upload_id, created_at
|
|
293
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
|
294
|
+
).run(
|
|
295
|
+
attachmentID,
|
|
296
|
+
assistantID,
|
|
297
|
+
genAfterStore,
|
|
298
|
+
stored.sha256,
|
|
299
|
+
stored.size,
|
|
300
|
+
stored.mime,
|
|
301
|
+
filename || null,
|
|
302
|
+
stored.relativePath,
|
|
303
|
+
upload,
|
|
304
|
+
at,
|
|
305
|
+
);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
const raced = getContactAttachmentByUploadID(db, assistantID, upload);
|
|
308
|
+
if (raced && raced.sha256 === stored.sha256 && raced.size === size) {
|
|
309
|
+
return canonicalDescriptorFromRow(raced);
|
|
310
|
+
}
|
|
311
|
+
if (String(error?.message || '').includes('UNIQUE') || error?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
|
312
|
+
fail('idempotency_conflict', 'Upload ID already used with different content');
|
|
313
|
+
}
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return canonicalDescriptorFromRow({
|
|
318
|
+
attachment_id: attachmentID,
|
|
319
|
+
sha256: stored.sha256,
|
|
320
|
+
size: stored.size,
|
|
321
|
+
mime: stored.mime,
|
|
322
|
+
filename: filename || null,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function readContactAttachmentBytes({
|
|
327
|
+
db,
|
|
328
|
+
dataDir,
|
|
329
|
+
assistantID,
|
|
330
|
+
attachmentID,
|
|
331
|
+
external = true,
|
|
332
|
+
}) {
|
|
333
|
+
const row = external
|
|
334
|
+
? assertContactAttachmentReadable(db, { attachmentID, assistantID })
|
|
335
|
+
: assertContactAttachmentOwned(db, { attachmentID, assistantID });
|
|
336
|
+
const { buffer, size } = await readPromptAttachmentBytes(dataDir, row.relative_path, {
|
|
337
|
+
maxBytes: Math.max(CONTACT_ATTACHMENT_HISTORICAL_MAX_BYTES, CONTACT_ATTACHMENT_MAX_BYTES * 4),
|
|
338
|
+
expectedSize: Number.isSafeInteger(row.size) ? row.size : undefined,
|
|
339
|
+
expectedSha256: typeof row.sha256 === 'string' && row.sha256 ? row.sha256 : undefined,
|
|
340
|
+
});
|
|
341
|
+
return {
|
|
342
|
+
buffer,
|
|
343
|
+
size,
|
|
344
|
+
mime: row.mime,
|
|
345
|
+
filename: row.filename,
|
|
346
|
+
sha256: row.sha256,
|
|
347
|
+
etag: `"${row.sha256}"`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Resolve canonical size for a file part (prefer descriptor size / row size).
|
|
353
|
+
* Returns null when unknown (must load to measure — counted after read).
|
|
354
|
+
*/
|
|
355
|
+
function canonicalFileSize(part) {
|
|
356
|
+
if (Number.isSafeInteger(part?.size) && part.size >= 0) return part.size;
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Materialize one file part. options.historySoftFail: missing/corrupt history
|
|
362
|
+
* attachments become an explicit unavailable text stub (does not throw).
|
|
363
|
+
* Current-turn materialize must leave historySoftFail false so missing files error.
|
|
364
|
+
*/
|
|
365
|
+
export async function materializeContactFilePart(db, dataDir, assistantID, part, {
|
|
366
|
+
historySoftFail = false,
|
|
367
|
+
} = {}) {
|
|
368
|
+
if (!part || part.type !== 'file') return null;
|
|
369
|
+
if (typeof part.url === 'string' && part.url.startsWith('data:')) {
|
|
370
|
+
return {
|
|
371
|
+
type: 'file',
|
|
372
|
+
mime: part.mime,
|
|
373
|
+
url: part.url,
|
|
374
|
+
size: canonicalFileSize(part) ?? undefined,
|
|
375
|
+
...(part.filename ? { filename: part.filename } : {}),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
if (typeof part.attachmentID === 'string' && part.attachmentID) {
|
|
379
|
+
try {
|
|
380
|
+
const { buffer, mime, filename, size, sha256 } = await readContactAttachmentBytes({
|
|
381
|
+
db,
|
|
382
|
+
dataDir,
|
|
383
|
+
assistantID,
|
|
384
|
+
attachmentID: part.attachmentID,
|
|
385
|
+
external: false,
|
|
386
|
+
});
|
|
387
|
+
// Integrity: if descriptor carried size/hash, they must match disk.
|
|
388
|
+
if (Number.isSafeInteger(part.size) && part.size !== size) {
|
|
389
|
+
fail('attachment_hash_mismatch', 'Attachment size does not match descriptor');
|
|
390
|
+
}
|
|
391
|
+
if (typeof part.sha256 === 'string' && part.sha256 && part.sha256 !== sha256) {
|
|
392
|
+
fail('attachment_hash_mismatch', 'Attachment digest does not match descriptor');
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
type: 'file',
|
|
396
|
+
mime,
|
|
397
|
+
url: `data:${mime};base64,${buffer.toString('base64')}`,
|
|
398
|
+
size,
|
|
399
|
+
sha256,
|
|
400
|
+
...(filename ? { filename } : {}),
|
|
401
|
+
};
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (historySoftFail) {
|
|
404
|
+
const name = typeof part.filename === 'string' && part.filename ? part.filename : part.attachmentID;
|
|
405
|
+
return {
|
|
406
|
+
type: 'text',
|
|
407
|
+
text: `[attachment unavailable: ${name}]`,
|
|
408
|
+
synthetic: true,
|
|
409
|
+
unavailableAttachmentID: part.attachmentID,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Materialize current-turn parts under an exact canonical-size budget.
|
|
420
|
+
* Exceeding budget → explicit error (no silent drop).
|
|
421
|
+
* Text always kept. signal aborts mid-loop.
|
|
422
|
+
*/
|
|
423
|
+
export async function materializeContactParts(db, dataDir, assistantID, parts = [], {
|
|
424
|
+
budgetBytes = CONTACT_ATTACHMENT_TURN_BUDGET_BYTES,
|
|
425
|
+
signal = null,
|
|
426
|
+
historySoftFail = false,
|
|
427
|
+
} = {}) {
|
|
428
|
+
const list = Array.isArray(parts) ? parts : [];
|
|
429
|
+
// Pre-sum known sizes — two 25MiB must fit in 50MiB; over budget fails closed.
|
|
430
|
+
let known = 0;
|
|
431
|
+
for (const part of list) {
|
|
432
|
+
if (part?.type !== 'file') continue;
|
|
433
|
+
const sz = canonicalFileSize(part);
|
|
434
|
+
if (sz != null) known += sz;
|
|
435
|
+
}
|
|
436
|
+
if (budgetBytes >= 0 && known > budgetBytes) {
|
|
437
|
+
fail('attachment_budget_exceeded', `Contact attachment materialize exceeds ${budgetBytes} byte budget`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const out = [];
|
|
441
|
+
let used = 0;
|
|
442
|
+
for (const part of list) {
|
|
443
|
+
if (signal?.aborted) fail('PROMPT_ATTACHMENT_ABORTED', 'Materialize aborted');
|
|
444
|
+
if (part?.type !== 'file') {
|
|
445
|
+
out.push(part);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const est = canonicalFileSize(part);
|
|
449
|
+
if (est != null && budgetBytes >= 0 && used + est > budgetBytes) {
|
|
450
|
+
fail('attachment_budget_exceeded', `Contact attachment materialize exceeds ${budgetBytes} byte budget`);
|
|
451
|
+
}
|
|
452
|
+
const materialized = await materializeContactFilePart(db, dataDir, assistantID, part, { historySoftFail });
|
|
453
|
+
if (!materialized) continue;
|
|
454
|
+
if (materialized.type === 'file') {
|
|
455
|
+
const actual = canonicalFileSize(materialized) ?? canonicalFileSize(part) ?? 0;
|
|
456
|
+
if (budgetBytes >= 0 && used + actual > budgetBytes) {
|
|
457
|
+
fail('attachment_budget_exceeded', `Contact attachment materialize exceeds ${budgetBytes} byte budget`);
|
|
458
|
+
}
|
|
459
|
+
used += actual;
|
|
460
|
+
}
|
|
461
|
+
out.push(materialized);
|
|
462
|
+
}
|
|
463
|
+
return out;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Group history into turns (user starts a turn; following non-user until next user).
|
|
468
|
+
* Select newest complete turns whose total canonical file sizes fit budget.
|
|
469
|
+
* Missing history attachments soft-fail to unavailable text — never block current turn.
|
|
470
|
+
*/
|
|
471
|
+
export async function materializeContactHistory(db, dataDir, assistantID, history = [], {
|
|
472
|
+
budgetBytes = CONTACT_ATTACHMENT_TURN_BUDGET_BYTES,
|
|
473
|
+
signal = null,
|
|
474
|
+
} = {}) {
|
|
475
|
+
const list = Array.isArray(history) ? history : [];
|
|
476
|
+
// Build turns: each turn is [user, ...followers] or orphan assistant/peer blocks.
|
|
477
|
+
const turns = [];
|
|
478
|
+
let current = [];
|
|
479
|
+
for (const message of list) {
|
|
480
|
+
if (message?.role === 'user' && current.length > 0) {
|
|
481
|
+
turns.push(current);
|
|
482
|
+
current = [message];
|
|
483
|
+
} else {
|
|
484
|
+
current.push(message);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (current.length) turns.push(current);
|
|
488
|
+
|
|
489
|
+
const turnFileBytes = (turn) => turn.reduce((sum, message) => (
|
|
490
|
+
sum + (message?.parts || [])
|
|
491
|
+
.filter((p) => p?.type === 'file')
|
|
492
|
+
.reduce((s, p) => s + (canonicalFileSize(p) ?? 0), 0)
|
|
493
|
+
), 0);
|
|
494
|
+
|
|
495
|
+
// Newest turns first until budget fills (complete turn groups only).
|
|
496
|
+
const selected = [];
|
|
497
|
+
let used = 0;
|
|
498
|
+
for (let i = turns.length - 1; i >= 0; i -= 1) {
|
|
499
|
+
const turn = turns[i];
|
|
500
|
+
const bytes = turnFileBytes(turn);
|
|
501
|
+
if (budgetBytes >= 0 && used + bytes > budgetBytes && selected.length > 0) break;
|
|
502
|
+
// Single oversized historical turn: skip entire turn rather than partial expand.
|
|
503
|
+
if (budgetBytes >= 0 && bytes > budgetBytes) continue;
|
|
504
|
+
used += bytes;
|
|
505
|
+
selected.push(turn);
|
|
506
|
+
}
|
|
507
|
+
selected.reverse();
|
|
508
|
+
|
|
509
|
+
const out = [];
|
|
510
|
+
for (const turn of selected) {
|
|
511
|
+
for (const message of turn) {
|
|
512
|
+
if (signal?.aborted) fail('PROMPT_ATTACHMENT_ABORTED', 'Materialize aborted');
|
|
513
|
+
if (!message?.parts?.length) {
|
|
514
|
+
out.push(message);
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
// Selected turns already fit the history budget as complete groups; expand fully
|
|
518
|
+
// with soft-fail so a missing historical file never blocks later turns.
|
|
519
|
+
out.push({
|
|
520
|
+
...message,
|
|
521
|
+
parts: await materializeContactParts(db, dataDir, assistantID, message.parts, {
|
|
522
|
+
budgetBytes: Number.MAX_SAFE_INTEGER,
|
|
523
|
+
signal,
|
|
524
|
+
historySoftFail: true,
|
|
525
|
+
}),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Estimate decoded payload bytes of a data URL without allocating the buffer. */
|
|
533
|
+
export function estimateDataUrlPayloadBytes(url) {
|
|
534
|
+
if (typeof url !== 'string' || !url.startsWith('data:')) return 0;
|
|
535
|
+
const comma = url.indexOf(',');
|
|
536
|
+
if (comma < 0) return 0;
|
|
537
|
+
const meta = url.slice(5, comma);
|
|
538
|
+
const payload = url.slice(comma + 1).replace(/\s+/g, '');
|
|
539
|
+
if (!payload) return 0;
|
|
540
|
+
if (/;base64/i.test(meta)) {
|
|
541
|
+
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0;
|
|
542
|
+
return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
return Buffer.byteLength(decodeURIComponent(payload), 'utf8');
|
|
546
|
+
} catch {
|
|
547
|
+
return Buffer.byteLength(payload, 'utf8');
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Persist a data-URL file part. mode=admission enforces 25MiB + safe mime.
|
|
553
|
+
* mode=migration allows larger historical payloads without bypassing unsafe mime.
|
|
554
|
+
* storeBytes inject allows tests to simulate disk write failure without editing fs store.
|
|
555
|
+
*/
|
|
556
|
+
export async function ingestDataUrlFilePart({
|
|
557
|
+
db,
|
|
558
|
+
dataDir,
|
|
559
|
+
assistantID,
|
|
560
|
+
part,
|
|
561
|
+
clock = () => Date.now(),
|
|
562
|
+
mode = 'admission',
|
|
563
|
+
storeBytes = storePromptAttachmentBytes,
|
|
564
|
+
signal = null,
|
|
565
|
+
} = {}) {
|
|
566
|
+
if (!part || part.type !== 'file') return null;
|
|
567
|
+
if (typeof part.attachmentID === 'string' && part.attachmentID) {
|
|
568
|
+
// Admission: must be current generation; rebuild canonical descriptor from row.
|
|
569
|
+
const row = mode === 'admission'
|
|
570
|
+
? assertContactAttachmentReadable(db, { attachmentID: part.attachmentID, assistantID })
|
|
571
|
+
: assertContactAttachmentOwned(db, { attachmentID: part.attachmentID, assistantID });
|
|
572
|
+
return canonicalDescriptorFromRow(row);
|
|
573
|
+
}
|
|
574
|
+
const url = typeof part.url === 'string' ? part.url : '';
|
|
575
|
+
if (!url.startsWith('data:')) {
|
|
576
|
+
fail('validation_error', 'File parts require attachmentID or data URL');
|
|
577
|
+
}
|
|
578
|
+
if (signal?.aborted) fail('PROMPT_ATTACHMENT_ABORTED', 'Ingest aborted');
|
|
579
|
+
const decoded = decodeDataUrlStrict(url, {
|
|
580
|
+
mode,
|
|
581
|
+
maxBytes: mode === 'migration' ? CONTACT_ATTACHMENT_HISTORICAL_MAX_BYTES : CONTACT_ATTACHMENT_MAX_BYTES,
|
|
582
|
+
});
|
|
583
|
+
const mime = part.mime || decoded.mime;
|
|
584
|
+
const genBefore = getContactGeneration(db, assistantID);
|
|
585
|
+
const stored = await storeBytes({
|
|
586
|
+
dataDir,
|
|
587
|
+
buffer: decoded.buffer,
|
|
588
|
+
mime,
|
|
589
|
+
filename: part.filename,
|
|
590
|
+
maxBytes: mode === 'migration'
|
|
591
|
+
? Math.min(CONTACT_ATTACHMENT_HISTORICAL_MAX_BYTES, Math.max(CONTACT_ATTACHMENT_MAX_BYTES, decoded.buffer.length))
|
|
592
|
+
: CONTACT_ATTACHMENT_MAX_BYTES,
|
|
593
|
+
// Admission: validate image magic when size is large enough for a header.
|
|
594
|
+
validateImage: mode === 'admission' && decoded.buffer.length >= 8 && String(mime).startsWith('image/'),
|
|
595
|
+
});
|
|
596
|
+
// After await: abort / generation must still match before any DB write.
|
|
597
|
+
if (signal?.aborted) fail('PROMPT_ATTACHMENT_ABORTED', 'Ingest aborted after store');
|
|
598
|
+
const genAfter = getContactGeneration(db, assistantID);
|
|
599
|
+
if (genAfter !== genBefore) {
|
|
600
|
+
fail('contact_generation_conflict', 'Contact generation changed during ingest');
|
|
601
|
+
}
|
|
602
|
+
// Integrity vs caller-supplied size/hash when present.
|
|
603
|
+
if (Number.isSafeInteger(part.size) && part.size !== stored.size) {
|
|
604
|
+
fail('attachment_hash_mismatch', 'Stored size does not match part descriptor');
|
|
605
|
+
}
|
|
606
|
+
if (typeof part.sha256 === 'string' && part.sha256 && part.sha256.toLowerCase() !== stored.sha256) {
|
|
607
|
+
fail('attachment_hash_mismatch', 'Stored digest does not match part descriptor');
|
|
608
|
+
}
|
|
609
|
+
if (stored.size !== decoded.buffer.length) {
|
|
610
|
+
fail('attachment_hash_mismatch', 'Stored size does not match decoded buffer');
|
|
611
|
+
}
|
|
612
|
+
const attachmentID = `att_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
613
|
+
db.prepare(
|
|
614
|
+
`INSERT INTO assistant_contact_attachment(
|
|
615
|
+
attachment_id, assistant_id, generation, sha256, size, mime, filename, relative_path, upload_id, created_at
|
|
616
|
+
) VALUES (?,?,?,?,?,?,?,?,NULL,?)`,
|
|
617
|
+
).run(
|
|
618
|
+
attachmentID,
|
|
619
|
+
assistantID,
|
|
620
|
+
genAfter,
|
|
621
|
+
stored.sha256,
|
|
622
|
+
stored.size,
|
|
623
|
+
stored.mime,
|
|
624
|
+
part.filename || null,
|
|
625
|
+
stored.relativePath,
|
|
626
|
+
clock(),
|
|
627
|
+
);
|
|
628
|
+
return canonicalDescriptorFromRow({
|
|
629
|
+
attachment_id: attachmentID,
|
|
630
|
+
sha256: stored.sha256,
|
|
631
|
+
size: stored.size,
|
|
632
|
+
mime: stored.mime,
|
|
633
|
+
filename: part.filename || null,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Keyset migration of legacy data-URL parts. One part at a time; 20/50MiB batches
|
|
639
|
+
* with real yield. CAS fences on part_json + generation. Failures diagnosed, continue.
|
|
640
|
+
* storeBytes inject simulates disk write failure without replacing bad dataURL paths.
|
|
641
|
+
*/
|
|
642
|
+
export async function migrateContactDataUrlParts({
|
|
643
|
+
db,
|
|
644
|
+
dataDir,
|
|
645
|
+
assistantID = null,
|
|
646
|
+
clock = () => Date.now(),
|
|
647
|
+
yieldFn = () => new Promise((r) => setImmediate(r)),
|
|
648
|
+
batchSize = CONTACT_ATTACHMENT_MIGRATE_BATCH,
|
|
649
|
+
batchBytes = CONTACT_ATTACHMENT_MIGRATE_BATCH_BYTES,
|
|
650
|
+
signal = null,
|
|
651
|
+
storeBytes = storePromptAttachmentBytes,
|
|
652
|
+
} = {}) {
|
|
653
|
+
let migrated = 0;
|
|
654
|
+
let failed = 0;
|
|
655
|
+
let scanned = 0;
|
|
656
|
+
const failures = [];
|
|
657
|
+
let batchCount = 0;
|
|
658
|
+
let batchByteAcc = 0;
|
|
659
|
+
let cursorMessageID = '';
|
|
660
|
+
let cursorPartID = '';
|
|
661
|
+
|
|
662
|
+
const selectSql = assistantID
|
|
663
|
+
? `SELECT p.message_id, p.part_id, p.ordinal, p.part_json, m.assistant_id
|
|
664
|
+
FROM assistant_contact_part p
|
|
665
|
+
JOIN assistant_contact_message m ON m.message_id = p.message_id
|
|
666
|
+
WHERE m.assistant_id=?
|
|
667
|
+
AND p.part_json LIKE '%data:%'
|
|
668
|
+
AND (p.message_id > ? OR (p.message_id = ? AND p.part_id > ?))
|
|
669
|
+
ORDER BY p.message_id ASC, p.part_id ASC
|
|
670
|
+
LIMIT 1`
|
|
671
|
+
: `SELECT p.message_id, p.part_id, p.ordinal, p.part_json, m.assistant_id
|
|
672
|
+
FROM assistant_contact_part p
|
|
673
|
+
JOIN assistant_contact_message m ON m.message_id = p.message_id
|
|
674
|
+
WHERE p.part_json LIKE '%data:%'
|
|
675
|
+
AND (p.message_id > ? OR (p.message_id = ? AND p.part_id > ?))
|
|
676
|
+
ORDER BY p.message_id ASC, p.part_id ASC
|
|
677
|
+
LIMIT 1`;
|
|
678
|
+
|
|
679
|
+
for (;;) {
|
|
680
|
+
if (signal?.aborted) break;
|
|
681
|
+
const row = assistantID
|
|
682
|
+
? db.prepare(selectSql).get(assistantID, cursorMessageID, cursorMessageID, cursorPartID)
|
|
683
|
+
: db.prepare(selectSql).get(cursorMessageID, cursorMessageID, cursorPartID);
|
|
684
|
+
if (!row) break;
|
|
685
|
+
cursorMessageID = row.message_id;
|
|
686
|
+
cursorPartID = row.part_id;
|
|
687
|
+
scanned += 1;
|
|
688
|
+
|
|
689
|
+
let part;
|
|
690
|
+
try {
|
|
691
|
+
part = JSON.parse(row.part_json);
|
|
692
|
+
} catch {
|
|
693
|
+
failed += 1;
|
|
694
|
+
failures.push({ messageID: row.message_id, partID: row.part_id, error: 'invalid_json' });
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
if (part?.type !== 'file' || typeof part.url !== 'string' || !part.url.startsWith('data:')) continue;
|
|
698
|
+
if (typeof part.attachmentID === 'string' && part.attachmentID) continue;
|
|
699
|
+
|
|
700
|
+
// Pre-estimate before decode/store — yield when adding would blow the batch budget.
|
|
701
|
+
const estBytes = estimateDataUrlPayloadBytes(part.url);
|
|
702
|
+
if (batchByteAcc > 0 && estBytes > 0 && batchByteAcc + estBytes > batchBytes) {
|
|
703
|
+
batchCount = 0;
|
|
704
|
+
batchByteAcc = 0;
|
|
705
|
+
await yieldFn();
|
|
706
|
+
if (signal?.aborted) break;
|
|
707
|
+
}
|
|
708
|
+
// Historical single item larger than batch budget: process alone after a yield boundary.
|
|
709
|
+
if (estBytes > batchBytes && batchByteAcc > 0) {
|
|
710
|
+
batchCount = 0;
|
|
711
|
+
batchByteAcc = 0;
|
|
712
|
+
await yieldFn();
|
|
713
|
+
if (signal?.aborted) break;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const genAtStart = getContactGeneration(db, row.assistant_id);
|
|
717
|
+
try {
|
|
718
|
+
// Disk first outside TX (injectable storeBytes for real write-failure tests).
|
|
719
|
+
const descriptor = await ingestDataUrlFilePart({
|
|
720
|
+
db,
|
|
721
|
+
dataDir,
|
|
722
|
+
assistantID: row.assistant_id,
|
|
723
|
+
part: {
|
|
724
|
+
type: 'file',
|
|
725
|
+
mime: part.mime,
|
|
726
|
+
url: part.url,
|
|
727
|
+
filename: part.filename,
|
|
728
|
+
...(Number.isSafeInteger(part.size) ? { size: part.size } : {}),
|
|
729
|
+
...(typeof part.sha256 === 'string' ? { sha256: part.sha256 } : {}),
|
|
730
|
+
},
|
|
731
|
+
clock,
|
|
732
|
+
mode: 'migration',
|
|
733
|
+
storeBytes,
|
|
734
|
+
signal,
|
|
735
|
+
});
|
|
736
|
+
// Post-await fences before DB commit (close / gen wipe).
|
|
737
|
+
if (signal?.aborted) {
|
|
738
|
+
failed += 1;
|
|
739
|
+
failures.push({ messageID: row.message_id, partID: row.part_id, error: 'aborted' });
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
const genAfterStore = getContactGeneration(db, row.assistant_id);
|
|
743
|
+
if (genAfterStore !== genAtStart) {
|
|
744
|
+
failed += 1;
|
|
745
|
+
failures.push({ messageID: row.message_id, partID: row.part_id, error: 'generation_changed' });
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (Number.isSafeInteger(part.size) && part.size !== descriptor.size) {
|
|
749
|
+
fail('attachment_hash_mismatch', 'Migrated size does not match source part');
|
|
750
|
+
}
|
|
751
|
+
if (typeof part.sha256 === 'string' && part.sha256 && part.sha256.toLowerCase() !== descriptor.sha256) {
|
|
752
|
+
fail('attachment_hash_mismatch', 'Migrated digest does not match source part');
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const nextJson = JSON.stringify({
|
|
756
|
+
id: part.id,
|
|
757
|
+
type: 'file',
|
|
758
|
+
mime: descriptor.mime,
|
|
759
|
+
attachmentID: descriptor.attachmentID,
|
|
760
|
+
sha256: descriptor.sha256,
|
|
761
|
+
size: descriptor.size,
|
|
762
|
+
...(descriptor.filename ? { filename: descriptor.filename } : {}),
|
|
763
|
+
});
|
|
764
|
+
// Transaction fence: generation unchanged + part_json CAS.
|
|
765
|
+
db.exec('BEGIN IMMEDIATE');
|
|
766
|
+
try {
|
|
767
|
+
const liveGen = getContactGeneration(db, row.assistant_id);
|
|
768
|
+
if (liveGen !== genAtStart) {
|
|
769
|
+
db.exec('ROLLBACK');
|
|
770
|
+
failed += 1;
|
|
771
|
+
failures.push({ messageID: row.message_id, partID: row.part_id, error: 'generation_changed' });
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
const result = db.prepare(
|
|
775
|
+
'UPDATE assistant_contact_part SET part_json=? WHERE message_id=? AND part_id=? AND part_json=?',
|
|
776
|
+
).run(nextJson, row.message_id, row.part_id, row.part_json);
|
|
777
|
+
db.exec('COMMIT');
|
|
778
|
+
if (result.changes === 1) {
|
|
779
|
+
migrated += 1;
|
|
780
|
+
batchCount += 1;
|
|
781
|
+
batchByteAcc += descriptor.size || estBytes || 0;
|
|
782
|
+
}
|
|
783
|
+
} catch (error) {
|
|
784
|
+
db.exec('ROLLBACK');
|
|
785
|
+
throw error;
|
|
786
|
+
}
|
|
787
|
+
} catch (error) {
|
|
788
|
+
failed += 1;
|
|
789
|
+
failures.push({
|
|
790
|
+
messageID: row.message_id,
|
|
791
|
+
partID: row.part_id,
|
|
792
|
+
error: error?.code || error?.message || 'migrate_failed',
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (batchCount >= batchSize || batchByteAcc >= batchBytes) {
|
|
797
|
+
batchCount = 0;
|
|
798
|
+
batchByteAcc = 0;
|
|
799
|
+
await yieldFn();
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return { migrated, failed, scanned, failures };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export {
|
|
807
|
+
absoluteObjectPath,
|
|
808
|
+
relativeObjectPath,
|
|
809
|
+
promptAttachmentFileExistsSync,
|
|
810
|
+
sha256Hex,
|
|
811
|
+
collectRequestBytes,
|
|
812
|
+
headerValue,
|
|
813
|
+
isSafeInlineImageMime,
|
|
814
|
+
};
|