@ixo/oracle-runtime-workers 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The two privacy primitives of anonymous feedback: screening the text for
3
+ * direct identifiers and secrets before it goes anywhere, and keyed
4
+ * pseudonyms for the user, session and message so issues can be grouped
5
+ * without revealing who wrote them or about which conversation.
6
+ */
7
+
8
+ /** Direct identifiers and credentials a feedback text must not carry. */
9
+ const DIRECT_IDENTIFIER_PATTERNS: ReadonlyArray<{
10
+ kind: string;
11
+ pattern: RegExp;
12
+ }> = [
13
+ {
14
+ kind: 'email address',
15
+ pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
16
+ },
17
+ { kind: 'Matrix identifier', pattern: /@[A-Z0-9._=-]+:[A-Z0-9.-]+/i },
18
+ {
19
+ kind: 'decentralized identifier',
20
+ pattern: /\bdid:[a-z0-9]+:[A-Za-z0-9._:%-]+\b/i,
21
+ },
22
+ {
23
+ kind: 'wallet address',
24
+ pattern: /\b(?:ixo|cosmos)1[0-9a-z]{20,}\b|\b0x[a-fA-F0-9]{40}\b/,
25
+ },
26
+ {
27
+ kind: 'credential',
28
+ pattern:
29
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:api[_ -]?key|password|secret|token|authorization)\s*[:=]\s*\S+/i,
30
+ },
31
+ {
32
+ kind: 'secret-bearing URL',
33
+ pattern:
34
+ /https?:\/\/\S+[?&](?:access_token|api_key|apikey|auth|key|password|secret|token)=/i,
35
+ },
36
+ ];
37
+
38
+ /**
39
+ * A run of digits and phone separators, opening at the start or after a
40
+ * space or `(`, closing before the end, a space or punctuation. A run can
41
+ * hold more than one number (a date, then a phone number), so every window
42
+ * of its whitespace-separated pieces is checked on its own.
43
+ */
44
+ const DIGIT_RUN = /(?:^|[\s(])([+(]?\d[\d\s().-]*\d\)?)(?=$|[\s.,;:)!?])/gu;
45
+ const ISO_DATE = /^\(?\d{4}-\d{2}-\d{2}\)?$/;
46
+ /** A grouped amount such as `1 000 000` or `1,250,000`. */
47
+ const GROUPED_AMOUNT = /^\d{1,3}(?:[ .,]\d{3})+$/;
48
+ /** E.164 allows 15 digits; national numbers have at least 9 with the trunk prefix. */
49
+ const PHONE_DIGITS = { min: 9, max: 15 };
50
+ /** Fifteen digits with every digit separated still fit: `+1 (234) 567-890-123-45` is 24. */
51
+ const PHONE_MAX_CHARS = 40;
52
+
53
+ function looksLikePhone(candidate: string): boolean {
54
+ if (!/^[+(]?\d/.test(candidate) || !/\d\)?$/.test(candidate)) return false;
55
+ const digits = candidate.replace(/\D/g, '').length;
56
+ return (
57
+ digits >= PHONE_DIGITS.min &&
58
+ digits <= PHONE_DIGITS.max &&
59
+ !GROUPED_AMOUNT.test(candidate)
60
+ );
61
+ }
62
+
63
+ function containsPhoneNumber(text: string): boolean {
64
+ for (const run of text.matchAll(DIGIT_RUN)) {
65
+ const pieces = run[1]!.split(/\s+/);
66
+ const digitCounts = pieces.map((p) => p.replace(/\D/g, '').length);
67
+ for (let start = 0; start < pieces.length; start += 1) {
68
+ let digits = 0;
69
+ let chars = 0;
70
+ for (let end = start; end < pieces.length; end += 1) {
71
+ // A date is never part of a phone number; wider windows keep it.
72
+ if (ISO_DATE.test(pieces[end]!)) break;
73
+ // A window past the longest possible number only grows, so stop
74
+ // there. Pieces of bare separators (`-`, `(`) carry no digit, hence
75
+ // the character bound as well: both keep the scan linear in the
76
+ // run's length instead of quadratic in its pieces.
77
+ digits += digitCounts[end]!;
78
+ chars += pieces[end]!.length + 1;
79
+ if (digits > PHONE_DIGITS.max || chars > PHONE_MAX_CHARS) break;
80
+ if (looksLikePhone(pieces.slice(start, end + 1).join(' '))) return true;
81
+ }
82
+ }
83
+ }
84
+ return false;
85
+ }
86
+
87
+ /**
88
+ * What is screened is what is sent: compatibility forms folded (a fullwidth
89
+ * `@` is `@`) and invisible format characters (zero-width spaces and joiners,
90
+ * bidi marks) removed, so neither can split an identifier past the patterns.
91
+ */
92
+ function normalizeFeedbackText(raw: string): string {
93
+ return raw
94
+ .normalize('NFKC')
95
+ .replace(/\p{Cf}/gu, '')
96
+ .trim();
97
+ }
98
+
99
+ export type FeedbackScreening =
100
+ | { ok: true; text: string }
101
+ | { ok: false; reason: 'empty' }
102
+ | { ok: false; reason: 'personal_data'; kind: string };
103
+
104
+ /**
105
+ * Normalise and trim the text, and reject it when it is empty or carries a direct
106
+ * identifier or a secret. The caller answers a rejection without saying
107
+ * which part matched, and never logs the text.
108
+ */
109
+ export function screenFeedbackText(raw: string): FeedbackScreening {
110
+ const text = normalizeFeedbackText(raw);
111
+ if (!text) return { ok: false, reason: 'empty' };
112
+ const match = DIRECT_IDENTIFIER_PATTERNS.find(({ pattern }) =>
113
+ pattern.test(text),
114
+ );
115
+ if (match) return { ok: false, reason: 'personal_data', kind: match.kind };
116
+ if (containsPhoneNumber(text))
117
+ return { ok: false, reason: 'personal_data', kind: 'phone number' };
118
+ return { ok: true, text };
119
+ }
120
+
121
+ export type FingerprintNamespace = 'user' | 'session' | 'message' | 'ip';
122
+
123
+ /**
124
+ * `<namespace>_<hex HMAC-SHA256>` over the namespace and the parts. Stable
125
+ * for one secret, unlinkable across namespaces, and not reversible without
126
+ * the secret — the same derivation the Node runtime used.
127
+ */
128
+ export async function feedbackFingerprint(
129
+ secret: string,
130
+ namespace: FingerprintNamespace,
131
+ ...parts: string[]
132
+ ): Promise<string> {
133
+ const encoder = new TextEncoder();
134
+ const key = await crypto.subtle.importKey(
135
+ 'raw',
136
+ encoder.encode(secret),
137
+ { name: 'HMAC', hash: 'SHA-256' },
138
+ false,
139
+ ['sign'],
140
+ );
141
+ const digest = await crypto.subtle.sign(
142
+ 'HMAC',
143
+ key,
144
+ encoder.encode([namespace, ...parts].join('\0')),
145
+ );
146
+ const hex = Array.from(new Uint8Array(digest), (b) =>
147
+ b.toString(16).padStart(2, '0'),
148
+ ).join('');
149
+ return `${namespace}_${hex}`;
150
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * The user object's half of anonymous feedback: check the message the
3
+ * feedback is about, apply the user's own submission limit, and keep the
4
+ * idempotency marker that makes one Agent message produce at most one issue.
5
+ *
6
+ * Only ids, a status and timestamps are stored — never the feedback text,
7
+ * which stays in the shell for the length of one request.
8
+ */
9
+ import type { DoSqliteDatabase } from '../sqlite/database';
10
+ import type { MessageDto } from '../do/transcript';
11
+ import type {
12
+ FeedbackReservation,
13
+ FeedbackSettlement,
14
+ FeedbackTarget,
15
+ } from './contract';
16
+
17
+ /** Submissions one user may start per window (the Node runtime's 3 per minute). */
18
+ export const FEEDBACK_USER_LIMIT = 3;
19
+ export const FEEDBACK_WINDOW_MS = 60_000;
20
+ /**
21
+ * A reservation the shell never settled (its isolate died mid-delivery) is
22
+ * reclaimed after this. Longer than the sink can take (three attempts of at
23
+ * most 8 s plus waits); the sink's own marker lookup stops a reclaimed
24
+ * delivery from creating a second issue.
25
+ */
26
+ export const FEEDBACK_PENDING_STALE_MS = 2 * 60_000;
27
+
28
+ interface MarkerRow extends Record<string, string | number | null> {
29
+ submission_id: string;
30
+ previous_submission_id: string | null;
31
+ status: string;
32
+ reserved_at: number;
33
+ submitted_at: string;
34
+ }
35
+
36
+ /**
37
+ * A completed Agent reply: an `ai` message of the transcript that is not
38
+ * part of the turn still running in the session. The listing marks every
39
+ * stored message complete, so the in-flight turn is told apart by position:
40
+ * while a run is active, everything after the last user message belongs to it.
41
+ */
42
+ export function isCompletedAgentMessage(
43
+ messages: readonly MessageDto[],
44
+ messageId: string,
45
+ runActive: boolean,
46
+ ): boolean {
47
+ const index = messages.findIndex((m) => m.id === messageId);
48
+ const message = index === -1 ? undefined : messages[index];
49
+ if (!message || message.type !== 'ai' || message.isComplete === false)
50
+ return false;
51
+ return (
52
+ !runActive || messages.slice(index + 1).some((m) => m.type === 'human')
53
+ );
54
+ }
55
+
56
+ /**
57
+ * A column of a feedback table. `add` is the `ALTER TABLE … ADD COLUMN`
58
+ * definition for a column added after the table first shipped; a column
59
+ * without one was in the first shape (a key or NOT NULL column SQLite cannot
60
+ * add in place), so a table lacking it is not one this runtime made.
61
+ */
62
+ interface ColumnSpec {
63
+ name: string;
64
+ add?: string;
65
+ }
66
+
67
+ const MARKER_COLUMNS: readonly ColumnSpec[] = [
68
+ { name: 'session_id' },
69
+ { name: 'message_id' },
70
+ { name: 'submission_id' },
71
+ { name: 'status' },
72
+ { name: 'reserved_at' },
73
+ { name: 'submitted_at' },
74
+ { name: 'previous_submission_id', add: 'TEXT' },
75
+ ];
76
+
77
+ const ATTEMPT_COLUMNS: readonly ColumnSpec[] = [{ name: 'at' }];
78
+
79
+ /** Add the columns an older table lacks; idempotent. */
80
+ async function ensureColumns(
81
+ db: DoSqliteDatabase,
82
+ table: string,
83
+ columns: readonly ColumnSpec[],
84
+ ): Promise<void> {
85
+ const present = new Set(
86
+ (await db.exec<{ name: string }>(`PRAGMA table_info(${table})`)).map(
87
+ (column) => column.name,
88
+ ),
89
+ );
90
+ for (const column of columns) {
91
+ if (present.has(column.name)) continue;
92
+ if (column.add === undefined)
93
+ throw new Error(
94
+ `${table} lacks the column ${column.name}, which cannot be added in place`,
95
+ );
96
+ await db.run(
97
+ `ALTER TABLE ${table} ADD COLUMN ${column.name} ${column.add}`,
98
+ );
99
+ }
100
+ }
101
+
102
+ export class FeedbackMarkers {
103
+ private setupPromise: Promise<void> | undefined;
104
+ private queue: Promise<unknown> = Promise.resolve();
105
+
106
+ constructor(
107
+ private readonly db: DoSqliteDatabase,
108
+ private readonly now: () => number = Date.now,
109
+ ) {}
110
+
111
+ setup(): Promise<void> {
112
+ this.setupPromise ??= (async () => {
113
+ await this.db.run(`CREATE TABLE IF NOT EXISTS message_feedback_markers (
114
+ session_id TEXT NOT NULL,
115
+ message_id TEXT NOT NULL,
116
+ submission_id TEXT NOT NULL,
117
+ -- the submission whose stale reservation this one took over, if any
118
+ previous_submission_id TEXT,
119
+ status TEXT NOT NULL CHECK (status IN ('pending', 'delivered')),
120
+ reserved_at INTEGER NOT NULL,
121
+ submitted_at TEXT NOT NULL,
122
+ PRIMARY KEY (session_id, message_id)
123
+ )`);
124
+ await this.db.run(`CREATE TABLE IF NOT EXISTS message_feedback_attempts (
125
+ at INTEGER NOT NULL
126
+ )`);
127
+ // CREATE TABLE IF NOT EXISTS leaves a table an older build created as
128
+ // it was: bring every column up to the current shape.
129
+ await ensureColumns(this.db, 'message_feedback_markers', MARKER_COLUMNS);
130
+ await ensureColumns(
131
+ this.db,
132
+ 'message_feedback_attempts',
133
+ ATTEMPT_COLUMNS,
134
+ );
135
+ })().catch((error: unknown) => {
136
+ this.setupPromise = undefined;
137
+ throw error;
138
+ });
139
+ return this.setupPromise;
140
+ }
141
+
142
+ /** Reservations and settlements run one at a time. */
143
+ private serialize<T>(work: () => Promise<T>): Promise<T> {
144
+ const next = this.queue.then(work);
145
+ this.queue = next.catch(() => undefined);
146
+ return next;
147
+ }
148
+
149
+ /**
150
+ * Reserve delivery for a message the caller has already validated. A
151
+ * replay of the delivered submission answers with it again; other
152
+ * feedback for the same message is a conflict; only a fresh reservation
153
+ * counts against the user's limit.
154
+ */
155
+ reserve(
156
+ target: FeedbackTarget,
157
+ ): Promise<Exclude<FeedbackReservation, { kind: 'not_found' }>> {
158
+ return this.serialize(async () => {
159
+ await this.setup();
160
+ return this.db.transaction(async () => {
161
+ const now = this.now();
162
+ const existing = await this.db.get<MarkerRow>(
163
+ `SELECT submission_id, previous_submission_id, status, reserved_at, submitted_at
164
+ FROM message_feedback_markers
165
+ WHERE session_id = ? AND message_id = ?`,
166
+ [target.sessionId, target.messageId],
167
+ );
168
+ if (existing?.status === 'delivered')
169
+ return existing.submission_id === target.submissionId
170
+ ? { kind: 'delivered', submittedAt: existing.submitted_at }
171
+ : { kind: 'conflict' };
172
+ const sameSubmission = existing?.submission_id === target.submissionId;
173
+ if (existing && now - existing.reserved_at < FEEDBACK_PENDING_STALE_MS)
174
+ return sameSubmission ? { kind: 'in_flight' } : { kind: 'conflict' };
175
+
176
+ await this.db.run(
177
+ 'DELETE FROM message_feedback_attempts WHERE at <= ?',
178
+ [now - FEEDBACK_WINDOW_MS],
179
+ );
180
+ const recent = await this.db.get<{ n: number }>(
181
+ 'SELECT COUNT(*) AS n FROM message_feedback_attempts',
182
+ );
183
+ if ((recent?.n ?? 0) >= FEEDBACK_USER_LIMIT)
184
+ return { kind: 'rate_limited' };
185
+ await this.db.run(
186
+ 'INSERT INTO message_feedback_attempts (at) VALUES (?)',
187
+ [now],
188
+ );
189
+
190
+ // A stale reservation of another submission may have created the
191
+ // issue before its isolate died; remember whose it was, so the shell
192
+ // never reports this submission as delivered when it sent nothing.
193
+ const previous = existing
194
+ ? sameSubmission
195
+ ? existing.previous_submission_id
196
+ : existing.submission_id
197
+ : null;
198
+ const submittedAt = new Date(now).toISOString();
199
+ await this.db.run(
200
+ `INSERT OR REPLACE INTO message_feedback_markers
201
+ (session_id, message_id, submission_id, previous_submission_id,
202
+ status, reserved_at, submitted_at)
203
+ VALUES (?, ?, ?, ?, 'pending', ?, ?)`,
204
+ [
205
+ target.sessionId,
206
+ target.messageId,
207
+ target.submissionId,
208
+ previous,
209
+ now,
210
+ submittedAt,
211
+ ],
212
+ );
213
+ return {
214
+ kind: 'reserved',
215
+ submittedAt,
216
+ replacesOtherSubmission: previous !== null,
217
+ };
218
+ });
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Settle this submission's reservation:
224
+ * - `delivered`: its issue exists — the marker is kept for good;
225
+ * - `superseded`: the issue that exists is the earlier submission's, so
226
+ * the marker is kept under that submission and this one becomes a
227
+ * conflict on any retry;
228
+ * - `released`: delivery failed — the marker is removed so the user can
229
+ * try again.
230
+ */
231
+ settle(target: FeedbackTarget, outcome: FeedbackSettlement): Promise<void> {
232
+ return this.serialize(async () => {
233
+ await this.setup();
234
+ const match = [target.sessionId, target.messageId, target.submissionId];
235
+ switch (outcome) {
236
+ case 'delivered':
237
+ await this.db.run(
238
+ `UPDATE message_feedback_markers SET status = 'delivered'
239
+ WHERE session_id = ? AND message_id = ? AND submission_id = ?`,
240
+ match,
241
+ );
242
+ return;
243
+ case 'superseded':
244
+ await this.db.run(
245
+ `UPDATE message_feedback_markers
246
+ SET status = 'delivered',
247
+ submission_id = COALESCE(previous_submission_id, submission_id)
248
+ WHERE session_id = ? AND message_id = ? AND submission_id = ?`,
249
+ match,
250
+ );
251
+ return;
252
+ case 'released':
253
+ await this.db.run(
254
+ `DELETE FROM message_feedback_markers
255
+ WHERE session_id = ? AND message_id = ? AND submission_id = ?
256
+ AND status = 'pending'`,
257
+ match,
258
+ );
259
+ return;
260
+ }
261
+ });
262
+ }
263
+
264
+ /** A deleted session's markers go with it. */
265
+ forgetSession(sessionId: string): Promise<void> {
266
+ return this.serialize(async () => {
267
+ await this.setup();
268
+ await this.db.run(
269
+ 'DELETE FROM message_feedback_markers WHERE session_id = ?',
270
+ [sessionId],
271
+ );
272
+ });
273
+ }
274
+ }
275
+
276
+ /** What {@link reserveFeedback} needs from the user object. */
277
+ export interface FeedbackReservationHost {
278
+ sessionExists(sessionId: string): Promise<boolean>;
279
+ transcript(sessionId: string): Promise<MessageDto[]>;
280
+ runActive(sessionId: string): Promise<boolean>;
281
+ markers: FeedbackMarkers;
282
+ }
283
+
284
+ /** Validate the target against the user's own database, then reserve. */
285
+ export async function reserveFeedback(
286
+ host: FeedbackReservationHost,
287
+ target: FeedbackTarget,
288
+ ): Promise<FeedbackReservation> {
289
+ if (!(await host.sessionExists(target.sessionId)))
290
+ return { kind: 'not_found' };
291
+ const [messages, runActive] = await Promise.all([
292
+ host.transcript(target.sessionId),
293
+ host.runActive(target.sessionId),
294
+ ]);
295
+ if (!isCompletedAgentMessage(messages, target.messageId, runActive))
296
+ return { kind: 'not_found' };
297
+ return host.markers.reserve(target);
298
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * `POST /messages/:sessionId/:messageId/feedback`, after authentication:
3
+ * validate, rate-limit, screen, reserve with the user's object, deliver to
4
+ * the sink, settle. The feedback text lives only in this request — it is
5
+ * never logged, never stored in the user's database and never sent to the
6
+ * user object.
7
+ */
8
+ import type { TurnIdentity } from '../do/contracts';
9
+ import type { FeedbackConfig } from './config';
10
+ import {
11
+ FeedbackSubmission,
12
+ type FeedbackDelivery,
13
+ type FeedbackReservation,
14
+ type FeedbackResponse,
15
+ type FeedbackSettlement,
16
+ type FeedbackSink,
17
+ type FeedbackTarget,
18
+ } from './contract';
19
+ import { LinearFeedbackError } from './linear-sink';
20
+ import { feedbackFingerprint, screenFeedbackText } from './privacy';
21
+
22
+ /** What the shell needs to serve the route; absent = the feature is off. */
23
+ export interface FeedbackShellOptions {
24
+ config: FeedbackConfig;
25
+ sink: FeedbackSink;
26
+ }
27
+
28
+ /** The user object's feedback RPCs (`UserOracleDO`). */
29
+ export interface FeedbackUserObject {
30
+ reserveMessageFeedback(
31
+ identity: TurnIdentity,
32
+ target: FeedbackTarget,
33
+ ): Promise<FeedbackReservation>;
34
+ settleMessageFeedback(
35
+ identity: TurnIdentity,
36
+ target: FeedbackTarget,
37
+ outcome: FeedbackSettlement,
38
+ ): Promise<void>;
39
+ }
40
+
41
+ export interface FeedbackLog {
42
+ info(message: string): void;
43
+ warn(message: string): void;
44
+ }
45
+
46
+ export interface SubmitFeedbackInput {
47
+ options: FeedbackShellOptions;
48
+ user: FeedbackUserObject;
49
+ identity: TurnIdentity;
50
+ sessionId: string;
51
+ messageId: string;
52
+ /** The parsed JSON body, or undefined when it was not JSON. */
53
+ body: unknown;
54
+ /** `cf-connecting-ip`; only its keyed pseudonym is used. */
55
+ clientIp: string | undefined;
56
+ ipLimiter: RateLimit | undefined;
57
+ log: FeedbackLog;
58
+ }
59
+
60
+ /** Machine-readable reasons, so clients branch without matching messages. */
61
+ export type FeedbackErrorCode =
62
+ | 'FEEDBACK_DISABLED'
63
+ | 'FEEDBACK_INVALID'
64
+ | 'FEEDBACK_EMPTY'
65
+ | 'FEEDBACK_TARGET_NOT_FOUND'
66
+ | 'FEEDBACK_IN_FLIGHT'
67
+ | 'FEEDBACK_ALREADY_SUBMITTED'
68
+ | 'FEEDBACK_CONTAINS_PERSONAL_DATA'
69
+ | 'FEEDBACK_RATE_LIMITED'
70
+ | 'FEEDBACK_DELIVERY_FAILED';
71
+
72
+ export interface FeedbackErrorBody {
73
+ statusCode: number;
74
+ code: FeedbackErrorCode;
75
+ message: string;
76
+ retryable: boolean;
77
+ }
78
+
79
+ export type FeedbackHttpResult =
80
+ | { status: 200; body: FeedbackResponse }
81
+ | { status: 400 | 404 | 409 | 422 | 429 | 502; body: FeedbackErrorBody };
82
+
83
+ export function feedbackFailure(
84
+ status: 400 | 404 | 409 | 422 | 429 | 502,
85
+ code: FeedbackErrorCode,
86
+ message: string,
87
+ retryable = false,
88
+ ): { status: typeof status; body: FeedbackErrorBody } {
89
+ return { status, body: { statusCode: status, code, message, retryable } };
90
+ }
91
+
92
+ const RATE_LIMITED = 'Too many feedback attempts. Please try again later.';
93
+
94
+ function errorName(error: unknown): string {
95
+ return error instanceof Error ? error.name : 'unknown error';
96
+ }
97
+
98
+ export async function submitFeedback(
99
+ input: SubmitFeedbackInput,
100
+ ): Promise<FeedbackHttpResult> {
101
+ const { options, identity, sessionId, messageId, log } = input;
102
+ const { config, sink } = options;
103
+ const parsed = FeedbackSubmission.safeParse(input.body);
104
+ if (!parsed.success)
105
+ return feedbackFailure(
106
+ 400,
107
+ 'FEEDBACK_INVALID',
108
+ 'Invalid feedback submission',
109
+ );
110
+ const { submissionId, context } = parsed.data;
111
+
112
+ if (input.ipLimiter) {
113
+ const ipKey = await feedbackFingerprint(
114
+ config.hmacSecret,
115
+ 'ip',
116
+ input.clientIp ?? 'unknown',
117
+ );
118
+ if (!(await input.ipLimiter.limit({ key: `feedback:${ipKey}` })).success)
119
+ return feedbackFailure(429, 'FEEDBACK_RATE_LIMITED', RATE_LIMITED, true);
120
+ }
121
+
122
+ const screened = screenFeedbackText(parsed.data.feedback);
123
+ if (!screened.ok)
124
+ return screened.reason === 'empty'
125
+ ? feedbackFailure(400, 'FEEDBACK_EMPTY', 'Feedback cannot be empty')
126
+ : feedbackFailure(
127
+ 422,
128
+ 'FEEDBACK_CONTAINS_PERSONAL_DATA',
129
+ 'Remove personal information, account identifiers, or secrets before submitting feedback',
130
+ );
131
+
132
+ const target: FeedbackTarget = { sessionId, messageId, submissionId };
133
+ const reservation = await input.user.reserveMessageFeedback(identity, target);
134
+ const submitted = (submittedAt: string): FeedbackHttpResult => ({
135
+ status: 200,
136
+ body: { submissionId, status: 'submitted', submittedAt },
137
+ });
138
+ const alreadySubmitted = feedbackFailure(
139
+ 409,
140
+ 'FEEDBACK_ALREADY_SUBMITTED',
141
+ 'Feedback for this message was already submitted',
142
+ );
143
+ switch (reservation.kind) {
144
+ case 'not_found':
145
+ return feedbackFailure(
146
+ 404,
147
+ 'FEEDBACK_TARGET_NOT_FOUND',
148
+ 'Completed Agent message not found',
149
+ );
150
+ case 'rate_limited':
151
+ return feedbackFailure(429, 'FEEDBACK_RATE_LIMITED', RATE_LIMITED, true);
152
+ case 'in_flight':
153
+ return feedbackFailure(
154
+ 409,
155
+ 'FEEDBACK_IN_FLIGHT',
156
+ 'This feedback is being delivered; retry shortly with the same submissionId',
157
+ true,
158
+ );
159
+ case 'conflict':
160
+ return alreadySubmitted;
161
+ case 'delivered':
162
+ return submitted(reservation.submittedAt);
163
+ case 'reserved':
164
+ break;
165
+ }
166
+
167
+ const { userDid } = identity;
168
+ const [userPseudonym, sessionFingerprint, messageFingerprint] =
169
+ await Promise.all([
170
+ feedbackFingerprint(config.hmacSecret, 'user', userDid),
171
+ feedbackFingerprint(config.hmacSecret, 'session', userDid, sessionId),
172
+ feedbackFingerprint(
173
+ config.hmacSecret,
174
+ 'message',
175
+ userDid,
176
+ sessionId,
177
+ messageId,
178
+ ),
179
+ ]);
180
+ let delivery: FeedbackDelivery;
181
+ try {
182
+ delivery = await sink.submit({
183
+ submissionId,
184
+ feedback: screened.text,
185
+ submittedAt: reservation.submittedAt,
186
+ userPseudonym,
187
+ sessionFingerprint,
188
+ messageFingerprint,
189
+ agent: config.agent,
190
+ context,
191
+ });
192
+ } catch (error) {
193
+ // The sink's own errors carry a status and code only; anything else is
194
+ // reduced to its class name so no upstream detail reaches the logs.
195
+ log.warn(
196
+ `[feedback] delivery failed: ${error instanceof LinearFeedbackError ? error.message : errorName(error)}`,
197
+ );
198
+ await input.user
199
+ .settleMessageFeedback(identity, target, 'released')
200
+ .catch((settleError: unknown) =>
201
+ log.warn(
202
+ `[feedback] could not release the reservation: ${errorName(settleError)}`,
203
+ ),
204
+ );
205
+ return feedbackFailure(
206
+ 502,
207
+ 'FEEDBACK_DELIVERY_FAILED',
208
+ 'Feedback could not be delivered. Please try again.',
209
+ true,
210
+ );
211
+ }
212
+
213
+ // This reservation took over a stale one of another submission, and the
214
+ // issue was already there: it is that submission's, and this text was not
215
+ // sent. Answer like any other second feedback for the message.
216
+ const superseded =
217
+ reservation.replacesOtherSubmission && delivery === 'existing';
218
+ // The issue exists either way; a settle that fails leaves a pending marker
219
+ // that a retry reclaims once stale, and the sink finds the issue then.
220
+ await input.user
221
+ .settleMessageFeedback(
222
+ identity,
223
+ target,
224
+ superseded ? 'superseded' : 'delivered',
225
+ )
226
+ .catch((settleError: unknown) =>
227
+ log.warn(
228
+ `[feedback] could not settle the reservation: ${errorName(settleError)}`,
229
+ ),
230
+ );
231
+ if (superseded) {
232
+ log.info('[feedback] an earlier submission already delivered the issue');
233
+ return alreadySubmitted;
234
+ }
235
+ log.info('[feedback] issue delivered');
236
+ return submitted(reservation.submittedAt);
237
+ }
238
+
239
+ /**
240
+ * Splice the capability flag into a transcript response the user object
241
+ * already serialised (`{"messages":...}` or a page), without re-parsing it.
242
+ */
243
+ export function withFeedbackCapability(json: string, enabled: boolean): string {
244
+ if (!enabled || !json.startsWith('{')) return json;
245
+ const rest = json.slice(1).trimStart();
246
+ return `{"capabilities":{"anonymousMessageFeedback":true}${rest.startsWith('}') ? '' : ','}${rest}`;
247
+ }