@ixo/oracle-runtime-workers 0.13.0 → 0.16.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.
- package/README.md +32 -10
- package/package.json +13 -8
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +310 -0
- package/src/channels/contract.ts +246 -0
- package/src/channels/turns.ts +280 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +170 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +2003 -274
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +504 -108
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +562 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- 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
|
+
}
|