larkin 0.2.70

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 (144) hide show
  1. package/CONTRIBUTING.md +48 -0
  2. package/LICENSE +201 -0
  3. package/README.md +122 -0
  4. package/SECURITY.md +14 -0
  5. package/artifacts/release/THIRD_PARTY_NOTICES.txt +5488 -0
  6. package/dist/agent/agent-cli-capabilities.cjs +43 -0
  7. package/dist/agent/agent-cli-capabilities.mjs +35 -0
  8. package/dist/agent/agent-state-store.cjs +961 -0
  9. package/dist/agent/agent-state-store.mjs +923 -0
  10. package/dist/agent/agent-transport.cjs +201 -0
  11. package/dist/agent/agent-transport.mjs +194 -0
  12. package/dist/agent/config-cli-contract.cjs +19 -0
  13. package/dist/agent/config-cli-contract.mjs +16 -0
  14. package/dist/agent/context-prompt.cjs +159 -0
  15. package/dist/agent/context-prompt.mjs +155 -0
  16. package/dist/agent/freshness-gate.mjs +24 -0
  17. package/dist/agent/history-projection.cjs +72 -0
  18. package/dist/agent/history-projection.mjs +65 -0
  19. package/dist/agent/host-reminder-orchestrator.cjs +289 -0
  20. package/dist/agent/host-reminder-orchestrator.mjs +252 -0
  21. package/dist/agent/inbox-projection.cjs +92 -0
  22. package/dist/agent/inbox-projection.mjs +85 -0
  23. package/dist/agent/interaction-state-machine.cjs +710 -0
  24. package/dist/agent/interaction-state-machine.mjs +705 -0
  25. package/dist/agent/reminder-routes.cjs +192 -0
  26. package/dist/agent/reminder-routes.mjs +156 -0
  27. package/dist/agent/reminder-store.cjs +220 -0
  28. package/dist/agent/reminder-store.mjs +205 -0
  29. package/dist/agent/transport-business-context.cjs +486 -0
  30. package/dist/agent/transport-business-context.mjs +446 -0
  31. package/dist/agent/transport-shell.cjs +45 -0
  32. package/dist/agent/transport-shell.mjs +39 -0
  33. package/dist/app/agent-cli.mjs +596 -0
  34. package/dist/app/agent-config.mjs +633 -0
  35. package/dist/app/agent-lark-cli-workspace.cjs +95 -0
  36. package/dist/app/agent-lark-cli-workspace.mjs +85 -0
  37. package/dist/app/agent-readiness.cjs +42 -0
  38. package/dist/app/agent-readiness.mjs +38 -0
  39. package/dist/app/binary-entry.mjs +86 -0
  40. package/dist/app/cli.mjs +176 -0
  41. package/dist/app/dashboard.mjs +205 -0
  42. package/dist/app/internal-command.cjs +57 -0
  43. package/dist/app/internal-command.mjs +48 -0
  44. package/dist/app/lark-channel-secret.mjs +22 -0
  45. package/dist/app/lark-cli.mjs +887 -0
  46. package/dist/app/lark.mjs +45 -0
  47. package/dist/app/local-control.mjs +728 -0
  48. package/dist/app/official-lark-cli.cjs +138 -0
  49. package/dist/app/official-lark-cli.mjs +128 -0
  50. package/dist/app/pi-auth-cli.mjs +67 -0
  51. package/dist/app/run.mjs +364 -0
  52. package/dist/app/runtime-agent-config.mjs +416 -0
  53. package/dist/app/runtime-model-directory.mjs +78 -0
  54. package/dist/app/runtime-process.mjs +113 -0
  55. package/dist/app/session-cli.mjs +83 -0
  56. package/dist/app/setup-dashboard.mjs +26 -0
  57. package/dist/app/setup.mjs +166 -0
  58. package/dist/app/telemetry.mjs +83 -0
  59. package/dist/dashboard/dashboard-assets.mjs +21 -0
  60. package/dist/dashboard/dashboard-avatar.mjs +128 -0
  61. package/dist/dashboard/dashboard-config-controller.mjs +525 -0
  62. package/dist/dashboard/dashboard-lifecycle.cjs +97 -0
  63. package/dist/dashboard/dashboard-lifecycle.mjs +88 -0
  64. package/dist/dashboard/dashboard-template.mjs +29 -0
  65. package/dist/dashboard/dashboard-view-model.mjs +556 -0
  66. package/dist/dashboard/dashboard-workspace.mjs +104 -0
  67. package/dist/dashboard/web/assets/dashboard.css +3 -0
  68. package/dist/dashboard/web/assets/dashboard.js +49 -0
  69. package/dist/feishu/channel-transport.cjs +151 -0
  70. package/dist/feishu/channel-transport.mjs +148 -0
  71. package/dist/feishu/document-comment.cjs +120 -0
  72. package/dist/feishu/document-comment.mjs +109 -0
  73. package/dist/feishu/host-business-state.cjs +477 -0
  74. package/dist/feishu/host-business-state.mjs +462 -0
  75. package/dist/feishu/host-channel-business.cjs +161 -0
  76. package/dist/feishu/host-channel-business.mjs +157 -0
  77. package/dist/feishu/host-processing-eye.cjs +185 -0
  78. package/dist/feishu/host-processing-eye.mjs +181 -0
  79. package/dist/feishu/host-shell.cjs +1425 -0
  80. package/dist/feishu/host-shell.mjs +1385 -0
  81. package/dist/feishu/im-freshness-adapter.mjs +89 -0
  82. package/dist/feishu/interaction-orchestrator.cjs +279 -0
  83. package/dist/feishu/interaction-orchestrator.mjs +242 -0
  84. package/dist/feishu/lark-passthrough.mjs +70 -0
  85. package/dist/feishu/message-policy.cjs +111 -0
  86. package/dist/feishu/message-policy.mjs +98 -0
  87. package/dist/feishu/outbound-transport.cjs +221 -0
  88. package/dist/feishu/outbound-transport.mjs +215 -0
  89. package/dist/platform/build-info.cjs +25 -0
  90. package/dist/platform/build-info.mjs +18 -0
  91. package/dist/platform/callback-capability.cjs +301 -0
  92. package/dist/platform/callback-capability.mjs +252 -0
  93. package/dist/platform/check-bun-version.cjs +12 -0
  94. package/dist/platform/config.cjs +751 -0
  95. package/dist/platform/config.mjs +694 -0
  96. package/dist/platform/process-inspect.cjs +89 -0
  97. package/dist/platform/process-state.cjs +237 -0
  98. package/dist/platform/process-state.mjs +223 -0
  99. package/dist/platform/release-artifacts.mjs +61 -0
  100. package/dist/platform/root-layout.cjs +111 -0
  101. package/dist/platform/root-layout.mjs +72 -0
  102. package/dist/platform/telemetry-config.cjs +64 -0
  103. package/dist/platform/telemetry-config.mjs +56 -0
  104. package/dist/platform/telemetry-spool.cjs +661 -0
  105. package/dist/platform/telemetry-spool.mjs +653 -0
  106. package/dist/platform/telemetry-tracing.cjs +602 -0
  107. package/dist/platform/telemetry-tracing.mjs +596 -0
  108. package/dist/platform/telemetry-uploader.cjs +150 -0
  109. package/dist/platform/telemetry-uploader.mjs +146 -0
  110. package/dist/platform/workspace-service.cjs +469 -0
  111. package/dist/platform/workspace-service.mjs +430 -0
  112. package/dist/runtime/builtin-pi-assets.mjs +33 -0
  113. package/dist/runtime/claude-model-catalog.cjs +102 -0
  114. package/dist/runtime/claude-model-catalog.mjs +99 -0
  115. package/dist/runtime/codex-model-catalog.cjs +108 -0
  116. package/dist/runtime/codex-model-catalog.mjs +105 -0
  117. package/dist/runtime/pi-bash-timeout-extension.mjs +60 -0
  118. package/dist/runtime/pi-bash-timeout-injection.mjs +60 -0
  119. package/dist/runtime/pi-bash-timeout.bundle.js +43 -0
  120. package/dist/runtime/pi-model-catalog.cjs +116 -0
  121. package/dist/runtime/pi-model-catalog.mjs +75 -0
  122. package/dist/runtime/pi-official-auth.mjs +480 -0
  123. package/dist/runtime/pi-provider-config.cjs +252 -0
  124. package/dist/runtime/pi-provider-config.mjs +239 -0
  125. package/dist/runtime/pi-rpc-client.cjs +255 -0
  126. package/dist/runtime/pi-rpc-client.mjs +251 -0
  127. package/dist/runtime/pi-subagent-injection.mjs +129 -0
  128. package/dist/runtime/pi-subagents.bundle.js +8990 -0
  129. package/dist/runtime/runtime-adapters.mjs +1019 -0
  130. package/dist/runtime/runtime-contracts.cjs +2 -0
  131. package/dist/runtime/runtime-contracts.mjs +1 -0
  132. package/dist/runtime/runtime-host.cjs +930 -0
  133. package/dist/runtime/runtime-host.mjs +922 -0
  134. package/dist/runtime/runtime-model-catalog.cjs +43 -0
  135. package/dist/runtime/runtime-model-catalog.mjs +40 -0
  136. package/dist/runtime/runtime-readiness.cjs +177 -0
  137. package/dist/runtime/runtime-readiness.mjs +133 -0
  138. package/dist/setup/bot-register.mjs +655 -0
  139. package/dist/setup/grant-scopes.mjs +133 -0
  140. package/dist/setup/run-credential-preflight.mjs +89 -0
  141. package/dist/setup/setup-agent-choice.mjs +172 -0
  142. package/dist/setup/setup-bind.mjs +337 -0
  143. package/dist/setup/setup-binding.mjs +52 -0
  144. package/package.json +112 -0
@@ -0,0 +1,705 @@
1
+ import { randomUUID, timingSafeEqual } from "node:crypto";
2
+ const EMPTY_STATE = () => ({ version: 1, definitions: [], instances: [], runs: [], action_refs: [], outbox: [] });
3
+ const ID = /^[a-z][a-z0-9_]{0,63}$/;
4
+ const OPEN_ID = /^ou_[A-Za-z0-9_-]{2,128}$/;
5
+ const CHAT_ID = /^oc_[A-Za-z0-9_-]{2,128}$/;
6
+ const MAX_SPEC_BYTES = 64 * 1024;
7
+ const MAX_RESULT_BYTES = 16 * 1024;
8
+ export const MAX_INTERACTION_STATE_BYTES = 5 * 1024 * 1024;
9
+ const MAX_INSTANCES = 500;
10
+ const MAX_RUNS = 500;
11
+ const MAX_OUTBOX = 1_000;
12
+ const REFLEX_RECOVERY_MS = 3_000;
13
+ const ALLOWED_DEFINITION_KEYS = new Set(["schema_version", "initial_state", "expires_in_seconds", "transition_limit", "retention_seconds", "audience", "states", "actions"]);
14
+ const FORBIDDEN_KEY = /(?:^|_)(?:code|script|command|shell|url|credential|secret|token)(?:$|_)/i;
15
+ const RESERVED_GRAPH_KEYS = new Set(["__proto__", "prototype", "constructor"]);
16
+ const PROCESSING_TITLE = "处理中";
17
+ const PROCESSING_MARKDOWN = "请求已受理,Agent 正在处理;当前状态不代表业务已经完成。";
18
+ const PROCESSING_TOAST = "已受理,Agent 正在处理,完成后会更新卡片。";
19
+ function record(value, label) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value))
21
+ throw new Error(`${label} must be an object`);
22
+ const prototype = Object.getPrototypeOf(value);
23
+ if (prototype !== Object.prototype && prototype !== null)
24
+ throw new Error(`${label} must be a plain or null-prototype object`);
25
+ return value;
26
+ }
27
+ function text(value, label, max = 2_000) {
28
+ if (typeof value !== "string" || !value.trim() || value.length > max)
29
+ throw new Error(`${label} must be a non-empty string <= ${max}`);
30
+ return value.trim();
31
+ }
32
+ function safeJson(value, label, max = MAX_RESULT_BYTES) {
33
+ let encoded;
34
+ try {
35
+ encoded = JSON.stringify(value);
36
+ }
37
+ catch {
38
+ throw new Error(`${label} must be JSON serializable`);
39
+ }
40
+ if (encoded === undefined || Buffer.byteLength(encoded) > max)
41
+ throw new Error(`${label} exceeds ${max} bytes`);
42
+ return JSON.parse(encoded);
43
+ }
44
+ function scanForbiddenKeys(value, path = "definition") {
45
+ if (!value || typeof value !== "object")
46
+ return;
47
+ if (Array.isArray(value)) {
48
+ value.forEach((item, index) => scanForbiddenKeys(item, `${path}[${index}]`));
49
+ return;
50
+ }
51
+ for (const [key, item] of Object.entries(value)) {
52
+ if (RESERVED_GRAPH_KEYS.has(key))
53
+ throw new Error(`${path}.${key} is reserved`);
54
+ if (FORBIDDEN_KEY.test(key))
55
+ throw new Error(`${path}.${key} is not allowed`);
56
+ scanForbiddenKeys(item, `${path}.${key}`);
57
+ }
58
+ }
59
+ function hasOwn(value, key) {
60
+ return Object.prototype.hasOwnProperty.call(value, key);
61
+ }
62
+ function validateResultSchema(value, actionId) {
63
+ const schema = record(value, `definition.actions.${actionId}.result_schema`);
64
+ if (Object.keys(schema).some((key) => !["properties", "required", "additional_properties"].includes(key)))
65
+ throw new Error(`action ${actionId}.result_schema contains unsupported fields`);
66
+ if (schema.additional_properties !== false)
67
+ throw new Error(`action ${actionId}.result_schema.additional_properties must be false`);
68
+ const rawProperties = record(schema.properties, `action ${actionId}.result_schema.properties`);
69
+ const entries = Object.entries(rawProperties);
70
+ if (entries.length > 20)
71
+ throw new Error(`action ${actionId}.result_schema supports at most 20 properties`);
72
+ const properties = Object.create(null);
73
+ for (const [name, rawProperty] of entries) {
74
+ if (!ID.test(name) || RESERVED_GRAPH_KEYS.has(name))
75
+ throw new Error(`action ${actionId}.result_schema property ${name} is invalid`);
76
+ const property = record(rawProperty, `action ${actionId}.result_schema.properties.${name}`);
77
+ if (Object.keys(property).some((key) => !["type", "max_length", "enum"].includes(key)))
78
+ throw new Error(`result property ${name} contains unsupported fields`);
79
+ if (!["string", "number", "integer", "boolean"].includes(property.type))
80
+ throw new Error(`result property ${name}.type is invalid`);
81
+ const maxLength = property.max_length === undefined ? undefined : Number(property.max_length);
82
+ if (maxLength !== undefined && (property.type !== "string" || !Number.isInteger(maxLength) || maxLength < 1 || maxLength > 4_000))
83
+ throw new Error(`result property ${name}.max_length is invalid`);
84
+ let enumValues;
85
+ if (property.enum !== undefined) {
86
+ if (!Array.isArray(property.enum) || property.enum.length < 1 || property.enum.length > 20 || property.enum.some((item) => !["string", "number", "boolean"].includes(typeof item)))
87
+ throw new Error(`result property ${name}.enum is invalid`);
88
+ enumValues = safeJson(property.enum, `result property ${name}.enum`, 4_000);
89
+ const enumTypeValid = enumValues.every((item) => property.type === "integer" ? Number.isInteger(item)
90
+ : property.type === "number" ? typeof item === "number" && Number.isFinite(item)
91
+ : typeof item === property.type);
92
+ if (!enumTypeValid)
93
+ throw new Error(`result property ${name}.enum does not match its declared type`);
94
+ }
95
+ properties[name] = { type: property.type, ...(maxLength === undefined ? {} : { max_length: maxLength }), ...(enumValues ? { enum: enumValues } : {}) };
96
+ }
97
+ if (schema.required !== undefined && (!Array.isArray(schema.required) || schema.required.length > 20))
98
+ throw new Error(`action ${actionId}.result_schema.required must be an array with at most 20 values`);
99
+ const required = schema.required === undefined || schema.required.length === 0
100
+ ? [] : stringArray(schema.required, `action ${actionId}.result_schema.required`, ID, 20);
101
+ for (const name of required)
102
+ if (!hasOwn(properties, name))
103
+ throw new Error(`result schema required property ${name} is not declared`);
104
+ return { properties, required, additional_properties: false };
105
+ }
106
+ function validateResultData(schema, value) {
107
+ const data = record(safeJson(value, "resolve data"), "resolve data");
108
+ for (const key of Object.keys(data)) {
109
+ if (RESERVED_GRAPH_KEYS.has(key) || !hasOwn(schema.properties, key))
110
+ throw new Error(`resolve data property ${key} is not declared`);
111
+ }
112
+ for (const key of schema.required)
113
+ if (!hasOwn(data, key))
114
+ throw new Error(`resolve data requires property ${key}`);
115
+ for (const [key, raw] of Object.entries(data)) {
116
+ const property = schema.properties[key];
117
+ const validType = property.type === "integer" ? Number.isInteger(raw)
118
+ : property.type === "number" ? typeof raw === "number" && Number.isFinite(raw)
119
+ : typeof raw === property.type;
120
+ if (!validType)
121
+ throw new Error(`resolve data property ${key} must be ${property.type}`);
122
+ if (property.type === "string" && property.max_length !== undefined && raw.length > property.max_length)
123
+ throw new Error(`resolve data property ${key} exceeds max_length`);
124
+ if (property.enum && !property.enum.some((candidate) => sameJson(candidate, raw)))
125
+ throw new Error(`resolve data property ${key} is outside enum`);
126
+ }
127
+ return data;
128
+ }
129
+ function stringArray(value, label, validator, max) {
130
+ if (!Array.isArray(value) || value.length < 1 || value.length > max)
131
+ throw new Error(`${label} must contain 1-${max} values`);
132
+ const result = value.map((item) => text(item, label, 160));
133
+ if (result.some((item) => !validator.test(item)))
134
+ throw new Error(`${label} contains an invalid value`);
135
+ if (new Set(result).size !== result.length)
136
+ throw new Error(`${label} contains duplicates`);
137
+ return result;
138
+ }
139
+ export function validateInteractionDefinition(input) {
140
+ const sanitized = safeJson(input, "definition", MAX_SPEC_BYTES);
141
+ scanForbiddenKeys(sanitized);
142
+ const source = record(sanitized, "definition");
143
+ for (const key of Object.keys(source))
144
+ if (!ALLOWED_DEFINITION_KEYS.has(key))
145
+ throw new Error(`definition.${key} is not allowed`);
146
+ if (source.schema_version !== 1)
147
+ throw new Error("definition.schema_version must be 1");
148
+ const initialState = text(source.initial_state, "definition.initial_state", 64);
149
+ if (!ID.test(initialState))
150
+ throw new Error("definition.initial_state is invalid");
151
+ const expires = Number(source.expires_in_seconds);
152
+ if (!Number.isInteger(expires) || expires < 30 || expires > 30 * 86_400)
153
+ throw new Error("definition.expires_in_seconds must be 30..2592000");
154
+ const transitionLimit = source.transition_limit === undefined ? 20 : Number(source.transition_limit);
155
+ if (!Number.isInteger(transitionLimit) || transitionLimit < 1 || transitionLimit > 100)
156
+ throw new Error("definition.transition_limit must be 1..100");
157
+ const retention = source.retention_seconds === undefined ? 7 * 86_400 : Number(source.retention_seconds);
158
+ if (!Number.isInteger(retention) || retention < 3600 || retention > 30 * 86_400)
159
+ throw new Error("definition.retention_seconds must be 3600..2592000");
160
+ const audienceSource = record(source.audience, "definition.audience");
161
+ if (Object.keys(audienceSource).some((key) => key !== "open_ids"))
162
+ throw new Error("definition.audience only supports open_ids");
163
+ const openIds = stringArray(audienceSource.open_ids, "definition.audience.open_ids", OPEN_ID, 100);
164
+ const stateSource = record(source.states, "definition.states");
165
+ const stateEntries = Object.entries(stateSource);
166
+ if (stateEntries.length < 2 || stateEntries.length > 20)
167
+ throw new Error("definition.states must contain 2-20 states");
168
+ const states = Object.create(null);
169
+ for (const [stateId, raw] of stateEntries) {
170
+ if (!ID.test(stateId))
171
+ throw new Error(`invalid state id: ${stateId}`);
172
+ const view = record(raw, `definition.states.${stateId}`);
173
+ if (Object.keys(view).some((key) => !["title", "markdown", "terminal"].includes(key)))
174
+ throw new Error(`definition.states.${stateId} contains unsupported fields`);
175
+ states[stateId] = {
176
+ title: text(view.title, `definition.states.${stateId}.title`, 120),
177
+ markdown: text(view.markdown, `definition.states.${stateId}.markdown`, 8_000),
178
+ ...(view.terminal === true ? { terminal: true } : {}),
179
+ };
180
+ }
181
+ if (!hasOwn(states, initialState))
182
+ throw new Error("definition.initial_state must exist");
183
+ if (states[initialState].terminal)
184
+ throw new Error("definition.initial_state cannot be terminal");
185
+ const actionSource = record(source.actions, "definition.actions");
186
+ const actionEntries = Object.entries(actionSource);
187
+ if (actionEntries.length < 1 || actionEntries.length > 20)
188
+ throw new Error("definition.actions must contain 1-20 actions");
189
+ const actions = Object.create(null);
190
+ for (const [actionId, raw] of actionEntries) {
191
+ if (!ID.test(actionId))
192
+ throw new Error(`invalid action id: ${actionId}`);
193
+ const action = record(raw, `definition.actions.${actionId}`);
194
+ if (Object.keys(action).some((key) => !["from", "label", "success_state", "failure_state", "timeout_state", "processing_state", "agent", "reflex", "result_schema"].includes(key))) {
195
+ throw new Error(`definition.actions.${actionId} contains unsupported fields`);
196
+ }
197
+ const from = stringArray(action.from, `definition.actions.${actionId}.from`, ID, 20);
198
+ const successState = text(action.success_state, `${actionId}.success_state`, 64);
199
+ const failureState = text(action.failure_state, `${actionId}.failure_state`, 64);
200
+ const timeoutState = action.timeout_state === undefined ? failureState : text(action.timeout_state, `${actionId}.timeout_state`, 64);
201
+ const processingState = text(action.processing_state, `${actionId}.processing_state`, 64);
202
+ for (const target of [...from, successState, failureState, timeoutState, processingState])
203
+ if (!hasOwn(states, target))
204
+ throw new Error(`action ${actionId} references missing state ${target}`);
205
+ if (from.some((stateId) => states[stateId].terminal))
206
+ throw new Error(`action ${actionId} cannot start from a terminal state`);
207
+ if (states[processingState].terminal)
208
+ throw new Error(`action ${actionId}.processing_state cannot be terminal`);
209
+ const agent = record(action.agent, `definition.actions.${actionId}.agent`);
210
+ if (Object.keys(agent).some((key) => !["instruction", "timeout_seconds"].includes(key)))
211
+ throw new Error(`action ${actionId}.agent contains unsupported fields`);
212
+ const timeout = agent.timeout_seconds === undefined ? 900 : Number(agent.timeout_seconds);
213
+ if (!Number.isInteger(timeout) || timeout < 30 || timeout > 86_400)
214
+ throw new Error(`action ${actionId}.agent.timeout_seconds must be 30..86400`);
215
+ const reflex = record(action.reflex, `definition.actions.${actionId}.reflex`);
216
+ if (Object.keys(reflex).some((key) => !["toast", "effect"].includes(key)))
217
+ throw new Error(`action ${actionId}.reflex contains unsupported fields`);
218
+ let effect;
219
+ if (reflex.effect !== undefined) {
220
+ const rawEffect = record(reflex.effect, `definition.actions.${actionId}.reflex.effect`);
221
+ if (rawEffect.id !== "reminder.schedule")
222
+ throw new Error(`unknown interaction effect: ${String(rawEffect.id)}`);
223
+ if (Object.keys(rawEffect).some((key) => !["id", "args"].includes(key)))
224
+ throw new Error("effect contains unsupported fields");
225
+ const args = record(rawEffect.args, "effect.args");
226
+ if (Object.keys(args).some((key) => !["title", "fire_at", "delay_seconds"].includes(key)))
227
+ throw new Error("reminder.schedule args contain unsupported fields");
228
+ const fireAt = args.fire_at === undefined ? undefined : text(args.fire_at, "effect.args.fire_at", 64);
229
+ const delay = args.delay_seconds === undefined ? undefined : Number(args.delay_seconds);
230
+ if ((fireAt === undefined) === (delay === undefined))
231
+ throw new Error("reminder.schedule requires exactly one of fire_at or delay_seconds");
232
+ if (fireAt && !Number.isFinite(Date.parse(fireAt)))
233
+ throw new Error("reminder.schedule fire_at is invalid");
234
+ if (delay !== undefined && (!Number.isInteger(delay) || delay < 1 || delay > 30 * 86_400))
235
+ throw new Error("reminder.schedule delay_seconds is invalid");
236
+ effect = { id: "reminder.schedule", args: { title: text(args.title, "effect.args.title", 300), ...(fireAt ? { fire_at: fireAt } : {}), ...(delay !== undefined ? { delay_seconds: delay } : {}) } };
237
+ }
238
+ actions[actionId] = {
239
+ from,
240
+ label: text(action.label, `${actionId}.label`, 80),
241
+ success_state: successState,
242
+ failure_state: failureState,
243
+ timeout_state: timeoutState,
244
+ processing_state: processingState,
245
+ agent: { instruction: text(agent.instruction, `${actionId}.agent.instruction`, 4_000), timeout_seconds: timeout },
246
+ reflex: { toast: text(reflex.toast, `${actionId}.reflex.toast`, 180), ...(effect ? { effect } : {}) },
247
+ result_schema: validateResultSchema(action.result_schema, actionId),
248
+ };
249
+ }
250
+ for (const stateId of Object.keys(states)) {
251
+ if (!states[stateId].terminal && !Object.values(actions).some((action) => action.from.includes(stateId) || action.processing_state === stateId)) {
252
+ throw new Error(`non-terminal state ${stateId} has no action`);
253
+ }
254
+ }
255
+ return { schema_version: 1, initial_state: initialState, expires_in_seconds: expires, transition_limit: transitionLimit, retention_seconds: retention, audience: { open_ids: openIds }, states, actions };
256
+ }
257
+ function renderCard(definition, instance, refs, summary) {
258
+ const view = instance.active_run_id
259
+ ? { title: PROCESSING_TITLE, markdown: PROCESSING_MARKDOWN }
260
+ : definition.states[instance.current_state];
261
+ const elements = [{ tag: "markdown", content: `${view.markdown}${summary ? `\n\n${summary}` : ""}` }];
262
+ if (!view.terminal && !instance.active_run_id) {
263
+ const buttons = Object.entries(definition.actions)
264
+ .filter(([, action]) => action.from.includes(instance.current_state))
265
+ .map(([actionId, action], index, available) => ({
266
+ tag: "button",
267
+ text: { tag: "plain_text", content: action.label },
268
+ type: index === 0 ? "primary_filled" : "default",
269
+ ...(available.length === 1 ? { width: "fill" } : {}),
270
+ behaviors: [{
271
+ type: "callback",
272
+ value: { interaction_ref: refs.get(actionId), interaction_version: instance.state_version },
273
+ }],
274
+ }));
275
+ elements.push(...buttons);
276
+ }
277
+ return { schema: "2.0", config: { update_multi: true }, header: { title: { tag: "plain_text", content: view.title } }, body: { elements } };
278
+ }
279
+ function sameJson(left, right) {
280
+ const a = Buffer.from(JSON.stringify(left));
281
+ const b = Buffer.from(JSON.stringify(right));
282
+ return a.length === b.length && timingSafeEqual(a, b);
283
+ }
284
+ export class InteractionStateMachine {
285
+ options;
286
+ now;
287
+ randomId;
288
+ constructor(options) {
289
+ this.options = options;
290
+ this.now = options.now ?? Date.now;
291
+ this.randomId = options.randomId ?? ((prefix) => `${prefix}_${randomUUID().replaceAll("-", "")}`);
292
+ }
293
+ pruneExpiredHistory(state) {
294
+ const now = this.now();
295
+ const definitionById = new Map(state.definitions.map((item) => [item.definition_id, item]));
296
+ const staleInstanceIds = new Set(state.instances
297
+ .filter((instance) => {
298
+ const definition = definitionById.get(instance.definition_id)?.spec;
299
+ const retentionSeconds = Number(definition?.retention_seconds ?? 7 * 86_400);
300
+ if (now < Date.parse(instance.expires_at) + retentionSeconds * 1_000 || instance.active_run_id)
301
+ return false;
302
+ const runIds = new Set(state.runs.filter((run) => run.instance_id === instance.instance_id).map((run) => run.run_id));
303
+ return state.runs.filter((run) => runIds.has(run.run_id)).every((run) => run.resolve !== null)
304
+ && state.outbox.filter((item) => runIds.has(item.run_id)).every((item) => item.status === "delivered");
305
+ })
306
+ .map((instance) => instance.instance_id));
307
+ if (staleInstanceIds.size === 0)
308
+ return;
309
+ const staleRunIds = new Set(state.runs.filter((run) => staleInstanceIds.has(run.instance_id)).map((run) => run.run_id));
310
+ state.instances = state.instances.filter((instance) => !staleInstanceIds.has(instance.instance_id));
311
+ state.runs = state.runs.filter((run) => !staleRunIds.has(run.run_id));
312
+ state.action_refs = state.action_refs.filter((ref) => !staleInstanceIds.has(ref.instance_id));
313
+ state.outbox = state.outbox.filter((item) => !staleRunIds.has(item.run_id));
314
+ const referencedDefinitionIds = new Set(state.instances.map((instance) => instance.definition_id));
315
+ state.definitions = state.definitions.filter((definition) => referencedDefinitionIds.has(definition.definition_id));
316
+ }
317
+ enforceBounds(state) {
318
+ if (state.instances.length > MAX_INSTANCES)
319
+ throw new Error(`interaction instance capacity ${MAX_INSTANCES} reached; wait for retention GC`);
320
+ if (state.outbox.length > MAX_OUTBOX) {
321
+ const removable = new Set(state.outbox.filter((item) => item.status === "delivered")
322
+ .slice(0, state.outbox.length - MAX_OUTBOX).map((item) => item.outbox_id));
323
+ state.outbox = state.outbox.filter((item) => !removable.has(item.outbox_id));
324
+ if (state.outbox.length > MAX_OUTBOX)
325
+ throw new Error(`interaction pending outbox capacity ${MAX_OUTBOX} reached`);
326
+ }
327
+ if (state.runs.length > MAX_RUNS) {
328
+ const pendingRunIds = new Set(state.outbox.filter((item) => item.status !== "delivered").map((item) => item.run_id));
329
+ const activeRunIds = new Set(state.instances.flatMap((instance) => instance.active_run_id ? [instance.active_run_id] : []));
330
+ const removable = new Set(state.runs.filter((run) => run.resolve && !pendingRunIds.has(run.run_id) && !activeRunIds.has(run.run_id))
331
+ .slice(0, state.runs.length - MAX_RUNS).map((run) => run.run_id));
332
+ state.runs = state.runs.filter((run) => !removable.has(run.run_id));
333
+ state.outbox = state.outbox.filter((item) => !removable.has(item.run_id));
334
+ if (state.runs.length > MAX_RUNS)
335
+ throw new Error(`interaction active run capacity ${MAX_RUNS} reached`);
336
+ }
337
+ const persistedBytes = Buffer.byteLength(`${JSON.stringify(state, null, 2)}\n`);
338
+ if (persistedBytes > MAX_INTERACTION_STATE_BYTES) {
339
+ throw new Error(`interaction state byte capacity ${MAX_INTERACTION_STATE_BYTES} reached; wait for retention GC`);
340
+ }
341
+ }
342
+ mutate(operation) {
343
+ return this.options.stateStore.mutateJson("interactions", EMPTY_STATE(), (state) => {
344
+ if (state.version !== 1 || !Array.isArray(state.definitions) || !Array.isArray(state.instances) || !Array.isArray(state.runs) || !Array.isArray(state.action_refs) || !Array.isArray(state.outbox))
345
+ throw new Error("interactions state is invalid");
346
+ this.pruneExpiredHistory(state);
347
+ const result = operation(state);
348
+ this.enforceBounds(state);
349
+ return result;
350
+ });
351
+ }
352
+ snapshot() {
353
+ return safeJson(this.options.stateStore.readJson("interactions", EMPTY_STATE()), "interaction state", MAX_INTERACTION_STATE_BYTES);
354
+ }
355
+ create(input) {
356
+ const definition = validateInteractionDefinition(input.definition);
357
+ const chatId = text(input.expected_chat_id, "expected_chat_id", 160);
358
+ if (!CHAT_ID.test(chatId))
359
+ throw new Error("expected_chat_id is invalid");
360
+ return this.mutate((state) => {
361
+ const now = this.now();
362
+ const at = new Date(now).toISOString();
363
+ const definitionId = this.randomId("def");
364
+ const instanceId = this.randomId("int");
365
+ const refs = new Map();
366
+ for (const actionId of Object.keys(definition.actions)) {
367
+ const ref = this.randomId("ref");
368
+ refs.set(actionId, ref);
369
+ state.action_refs.push({ ref, instance_id: instanceId, action_id: actionId });
370
+ }
371
+ const instance = {
372
+ instance_id: instanceId,
373
+ definition_id: definitionId,
374
+ owner_agent_id: this.options.agentId,
375
+ expected_chat_id: chatId,
376
+ message_id: null,
377
+ current_state: definition.initial_state,
378
+ state_version: 1,
379
+ active_run_id: null,
380
+ transition_count: 0,
381
+ expires_at: new Date(now + definition.expires_in_seconds * 1_000).toISOString(),
382
+ desired_projection_version: 1,
383
+ projected_version: 0,
384
+ created_at: at,
385
+ updated_at: at,
386
+ };
387
+ state.definitions.push({ definition_id: definitionId, owner_agent_id: this.options.agentId, spec: definition, created_at: at });
388
+ state.instances.push(instance);
389
+ return { definition, instance: safeJson(instance, "instance"), card: renderCard(definition, instance, refs) };
390
+ });
391
+ }
392
+ get(locator) {
393
+ const state = this.snapshot();
394
+ const run = locator.run_id ? state.runs.find((item) => item.run_id === locator.run_id) : undefined;
395
+ const instanceId = locator.instance_id || run?.instance_id;
396
+ if (!instanceId)
397
+ throw new Error("instance_id or run_id is required");
398
+ const instance = state.instances.find((item) => item.instance_id === instanceId);
399
+ if (!instance)
400
+ throw new Error("interaction instance not found");
401
+ const definition = state.definitions.find((item) => item.definition_id === instance.definition_id)?.spec;
402
+ if (!definition)
403
+ throw new Error("interaction definition not found");
404
+ const refs = new Map(state.action_refs.filter((item) => item.instance_id === instanceId).map((item) => [item.action_id, item.ref]));
405
+ return { definition, instance, ...(run ? { run } : {}), card: renderCard(definition, instance, refs, run?.resolve?.summary) };
406
+ }
407
+ claim(input) {
408
+ return this.mutate((state) => {
409
+ const prior = state.runs.find((item) => item.callback_id === input.callback_id);
410
+ if (prior) {
411
+ const instance = state.instances.find((item) => item.instance_id === prior.instance_id);
412
+ const definition = state.definitions.find((item) => item.definition_id === instance.definition_id).spec;
413
+ const refs = new Map(state.action_refs.filter((item) => item.instance_id === instance.instance_id).map((item) => [item.action_id, item.ref]));
414
+ return { duplicate: true, definition, instance: safeJson(instance, "instance"), run: safeJson(prior, "run"), card: renderCard(definition, instance, refs, prior.resolve?.summary), toast: definition.actions[prior.action_id].reflex.toast };
415
+ }
416
+ const actionRef = state.action_refs.find((item) => item.ref === input.interaction_ref);
417
+ if (!actionRef)
418
+ throw new Error("interaction reference is invalid");
419
+ const instance = state.instances.find((item) => item.instance_id === actionRef.instance_id);
420
+ if (!instance)
421
+ throw new Error("interaction instance not found");
422
+ const definition = state.definitions.find((item) => item.definition_id === instance.definition_id)?.spec;
423
+ if (!definition)
424
+ throw new Error("interaction definition not found");
425
+ const action = definition.actions[actionRef.action_id];
426
+ const now = this.now();
427
+ if (now >= Date.parse(instance.expires_at))
428
+ throw new Error("interaction has expired");
429
+ if (instance.expected_chat_id !== input.chat_id)
430
+ throw new Error("interaction chat is not allowed");
431
+ if (!definition.audience.open_ids.includes(input.operator_open_id))
432
+ throw new Error("interaction operator is not allowed");
433
+ if (instance.message_id && instance.message_id !== input.message_id)
434
+ throw new Error("interaction card message is not allowed");
435
+ if (instance.active_run_id)
436
+ throw new Error("interaction already has an active run");
437
+ if (!Number.isSafeInteger(input.expected_version) || input.expected_version !== instance.state_version)
438
+ throw new Error("interaction card version is stale");
439
+ if (!action.from.includes(instance.current_state))
440
+ throw new Error("interaction action is not allowed from current state");
441
+ if (instance.transition_count >= Number(definition.transition_limit))
442
+ throw new Error("interaction transition limit reached");
443
+ const at = new Date(now).toISOString();
444
+ const runId = this.randomId("run");
445
+ const sourceState = instance.current_state;
446
+ const sourceVersion = instance.state_version;
447
+ instance.message_id = input.message_id;
448
+ instance.current_state = action.processing_state;
449
+ instance.state_version += 1;
450
+ instance.active_run_id = runId;
451
+ instance.transition_count += 1;
452
+ instance.desired_projection_version += 1;
453
+ instance.updated_at = at;
454
+ const run = {
455
+ run_id: runId,
456
+ instance_id: instance.instance_id,
457
+ action_id: actionRef.action_id,
458
+ callback_id: text(input.callback_id, "callback_id", 300),
459
+ operator_open_id: input.operator_open_id,
460
+ chat_id: input.chat_id,
461
+ message_id: input.message_id,
462
+ source_state: sourceState,
463
+ source_version: sourceVersion,
464
+ expected_resolve_version: instance.state_version,
465
+ callback_received_at: at,
466
+ reflex_deadline_at: new Date(now + REFLEX_RECOVERY_MS).toISOString(),
467
+ reflex_completed_at: null,
468
+ agent_deadline_at: new Date(now + Number(action.agent.timeout_seconds) * 1_000).toISOString(),
469
+ reflex: { status: "pending", summary: "", data: {} },
470
+ agent_delivery_status: "pending",
471
+ resolve: null,
472
+ created_at: at,
473
+ updated_at: at,
474
+ };
475
+ state.runs.push(run);
476
+ state.outbox.push({ outbox_id: this.randomId("out"), kind: "card_projection", run_id: runId, status: "pending", payload: { instance_id: instance.instance_id, desired_version: instance.desired_projection_version }, created_at: at, updated_at: at, attempts: 0 });
477
+ state.outbox.push({ outbox_id: this.randomId("out"), kind: "agent_wake", run_id: runId, status: "waiting_reflex", payload: {}, created_at: at, updated_at: at, attempts: 0 });
478
+ const refs = new Map(state.action_refs.filter((item) => item.instance_id === instance.instance_id).map((item) => [item.action_id, item.ref]));
479
+ return { duplicate: false, definition, instance: safeJson(instance, "instance"), run: safeJson(run, "run"), card: renderCard(definition, instance, refs), toast: action.reflex.toast };
480
+ });
481
+ }
482
+ finalizeReflex(state, run, result) {
483
+ if (run.reflex.status !== "pending")
484
+ return;
485
+ const instance = state.instances.find((item) => item.instance_id === run.instance_id);
486
+ const definition = instance && state.definitions.find((item) => item.definition_id === instance.definition_id)?.spec;
487
+ const action = definition?.actions[run.action_id];
488
+ const wake = state.outbox.find((item) => item.kind === "agent_wake" && item.run_id === run.run_id);
489
+ if (!instance || !definition || !action || !wake)
490
+ throw new Error("interaction Reflex recovery state is incomplete");
491
+ const at = new Date(this.now()).toISOString();
492
+ run.reflex = { status: result.status, summary: text(result.summary, "reflex summary", 1_000), data: safeJson(result.data ?? {}, "reflex data") };
493
+ run.reflex_completed_at = at;
494
+ run.updated_at = at;
495
+ wake.status = "pending";
496
+ wake.updated_at = at;
497
+ wake.payload = {
498
+ message_id: `interaction_${run.run_id}`,
499
+ wake: true,
500
+ kind: "interaction",
501
+ sender_type: "human",
502
+ sender_id: run.operator_open_id,
503
+ chat_id: run.chat_id,
504
+ card_message_id: run.message_id,
505
+ interaction_instance_id: run.instance_id,
506
+ interaction_run_id: run.run_id,
507
+ action_id: run.action_id,
508
+ source_state: run.source_state,
509
+ source_version: run.source_version,
510
+ expected_resolve_version: run.expected_resolve_version,
511
+ agent_instruction: action.agent.instruction,
512
+ reflex: run.reflex,
513
+ side_effect_status: run.reflex.status,
514
+ content: `Interactive card action requires Agent handling. Run interaction get --run-id ${run.run_id}, then interaction resolve --run-id ${run.run_id} --expected-version ${run.expected_resolve_version} --status <succeeded|failed> --summary <text>.`,
515
+ };
516
+ }
517
+ recordReflex(runId, result) {
518
+ return this.mutate((state) => {
519
+ const run = state.runs.find((item) => item.run_id === runId);
520
+ if (!run)
521
+ throw new Error("interaction run not found");
522
+ if (run.reflex.status !== "pending")
523
+ return safeJson(run, "run");
524
+ this.finalizeReflex(state, run, result);
525
+ return safeJson(run, "run");
526
+ });
527
+ }
528
+ recoverInterruptedReflexes() {
529
+ return this.mutate((state) => {
530
+ const now = this.now();
531
+ let recovered = 0;
532
+ for (const run of state.runs) {
533
+ const deadline = Number.isFinite(Date.parse(run.reflex_deadline_at))
534
+ ? Date.parse(run.reflex_deadline_at) : Date.parse(run.created_at) + REFLEX_RECOVERY_MS;
535
+ if (run.reflex.status !== "pending" || now < deadline)
536
+ continue;
537
+ this.finalizeReflex(state, run, {
538
+ status: "uncertain",
539
+ summary: "Reflex execution was interrupted before its durable outcome was recorded; inspect before retrying.",
540
+ data: { category: "reflex_interrupted" },
541
+ });
542
+ recovered += 1;
543
+ }
544
+ return recovered;
545
+ });
546
+ }
547
+ resolve(input) {
548
+ return this.mutate((state) => {
549
+ const run = state.runs.find((item) => item.run_id === input.run_id);
550
+ if (!run)
551
+ throw new Error("interaction run not found");
552
+ const instance = state.instances.find((item) => item.instance_id === run.instance_id);
553
+ const definition = state.definitions.find((item) => item.definition_id === instance.definition_id).spec;
554
+ const summary = text(input.summary, "resolve summary", 1_000);
555
+ const action = definition.actions[run.action_id];
556
+ const data = validateResultData(action.result_schema, input.data ?? {});
557
+ if (run.resolve) {
558
+ if (run.resolve.status === input.status && run.resolve.summary === summary && sameJson(run.resolve.data, data)) {
559
+ const refs = new Map(state.action_refs.filter((item) => item.instance_id === instance.instance_id).map((item) => [item.action_id, item.ref]));
560
+ return { instance: safeJson(instance, "instance"), run: safeJson(run, "run"), card: renderCard(definition, instance, refs, summary), idempotent: true };
561
+ }
562
+ throw new Error("interaction run is already terminal");
563
+ }
564
+ if (input.expected_version !== instance.state_version || input.expected_version !== run.expected_resolve_version)
565
+ throw new Error("interaction version conflict");
566
+ if (input.agent_id !== instance.owner_agent_id)
567
+ throw new Error("interaction owner mismatch");
568
+ if (instance.active_run_id !== run.run_id)
569
+ throw new Error("interaction run is not active");
570
+ if (this.now() >= Date.parse(instance.expires_at))
571
+ throw new Error("interaction has expired; wait for timeout recovery instead of resolving it");
572
+ const at = new Date(this.now()).toISOString();
573
+ instance.current_state = input.status === "succeeded" ? action.success_state : action.failure_state;
574
+ instance.state_version += 1;
575
+ instance.active_run_id = null;
576
+ instance.desired_projection_version += 1;
577
+ instance.updated_at = at;
578
+ run.resolve = { status: input.status, summary, data, resolved_at: at };
579
+ run.updated_at = at;
580
+ state.outbox.push({ outbox_id: this.randomId("out"), kind: "card_projection", run_id: run.run_id, status: "pending", payload: { instance_id: instance.instance_id, desired_version: instance.desired_projection_version }, created_at: at, updated_at: at, attempts: 0 });
581
+ const refs = new Map(state.action_refs.filter((item) => item.instance_id === instance.instance_id).map((item) => [item.action_id, item.ref]));
582
+ return { instance: safeJson(instance, "instance"), run: safeJson(run, "run"), card: renderCard(definition, instance, refs, summary), idempotent: false };
583
+ });
584
+ }
585
+ expireTimedOutRuns() {
586
+ return this.mutate((state) => {
587
+ const now = this.now();
588
+ const at = new Date(now).toISOString();
589
+ let count = 0;
590
+ for (const run of state.runs) {
591
+ const instance = state.instances.find((item) => item.instance_id === run.instance_id);
592
+ if (!instance || instance.active_run_id !== run.run_id)
593
+ continue;
594
+ const instanceExpired = now >= Date.parse(instance.expires_at);
595
+ if (run.resolve || (!instanceExpired && now < Date.parse(run.agent_deadline_at)))
596
+ continue;
597
+ const definition = state.definitions.find((item) => item.definition_id === instance.definition_id)?.spec;
598
+ const action = definition?.actions[run.action_id];
599
+ if (!definition || !action)
600
+ continue;
601
+ instance.current_state = action.timeout_state;
602
+ instance.state_version += 1;
603
+ instance.active_run_id = null;
604
+ instance.desired_projection_version += 1;
605
+ instance.updated_at = at;
606
+ run.resolve = { status: "timed_out", summary: instanceExpired
607
+ ? "Interaction expired before the Agent resolved it."
608
+ : "Agent did not resolve before the declared deadline.", data: {}, resolved_at: at };
609
+ run.updated_at = at;
610
+ state.outbox.push({ outbox_id: this.randomId("out"), kind: "card_projection", run_id: run.run_id, status: "pending", payload: { instance_id: instance.instance_id, desired_version: instance.desired_projection_version }, created_at: at, updated_at: at, attempts: 0 });
611
+ count += 1;
612
+ }
613
+ return count;
614
+ });
615
+ }
616
+ pendingMaintenance() {
617
+ const observedAt = this.now();
618
+ const observed = this.snapshot();
619
+ const interruptedReflex = observed.runs.some((run) => {
620
+ const deadline = Number.isFinite(Date.parse(run.reflex_deadline_at))
621
+ ? Date.parse(run.reflex_deadline_at) : Date.parse(run.created_at) + REFLEX_RECOVERY_MS;
622
+ return run.reflex.status === "pending" && observedAt >= deadline;
623
+ });
624
+ const timedOutRun = observed.runs.some((run) => {
625
+ if (run.resolve)
626
+ return false;
627
+ const instance = observed.instances.find((item) => item.instance_id === run.instance_id);
628
+ return instance?.active_run_id === run.run_id
629
+ && (observedAt >= Date.parse(run.agent_deadline_at) || observedAt >= Date.parse(instance.expires_at));
630
+ });
631
+ return { interrupted_reflex: interruptedReflex, timed_out_run: timedOutRun };
632
+ }
633
+ pendingOutbox(kind) {
634
+ return this.snapshot().outbox.filter((item) => item.status === "pending" && (!kind || item.kind === kind));
635
+ }
636
+ prepareProjection(outboxId) {
637
+ return this.mutate((state) => {
638
+ const item = state.outbox.find((candidate) => candidate.outbox_id === outboxId);
639
+ if (!item || item.kind !== "card_projection" || item.status !== "pending")
640
+ return null;
641
+ const instanceId = String(item.payload.instance_id || "");
642
+ const desiredVersion = Number(item.payload.desired_version);
643
+ const instance = state.instances.find((candidate) => candidate.instance_id === instanceId);
644
+ if (!instance)
645
+ throw new Error("card projection instance not found");
646
+ if (desiredVersion !== instance.desired_projection_version || desiredVersion <= instance.projected_version) {
647
+ item.status = "delivered";
648
+ item.updated_at = new Date(this.now()).toISOString();
649
+ item.last_error = "superseded before delivery";
650
+ return null;
651
+ }
652
+ if (!instance.message_id)
653
+ throw new Error("card message is not bound");
654
+ const definition = state.definitions.find((candidate) => candidate.definition_id === instance.definition_id)?.spec;
655
+ if (!definition)
656
+ throw new Error("card projection definition not found");
657
+ const refs = new Map(state.action_refs.filter((ref) => ref.instance_id === instance.instance_id).map((ref) => [ref.action_id, ref.ref]));
658
+ const run = state.runs.find((candidate) => candidate.run_id === item.run_id);
659
+ return { message_id: instance.message_id, desired_version: desiredVersion, card: renderCard(definition, instance, refs, run?.resolve?.summary) };
660
+ });
661
+ }
662
+ completeProjection(outboxId, expectedVersion, result) {
663
+ this.mutate((state) => {
664
+ const item = state.outbox.find((candidate) => candidate.outbox_id === outboxId);
665
+ if (!item || item.kind !== "card_projection")
666
+ throw new Error("card projection outbox item not found");
667
+ item.attempts += 1;
668
+ item.updated_at = new Date(this.now()).toISOString();
669
+ if (!result.delivered) {
670
+ item.status = "pending";
671
+ item.last_error = text(result.error || "delivery failed", "outbox error", 500);
672
+ return;
673
+ }
674
+ item.status = "delivered";
675
+ const instance = state.instances.find((candidate) => candidate.instance_id === String(item.payload.instance_id || ""));
676
+ if (!instance)
677
+ return;
678
+ if (instance.desired_projection_version === expectedVersion) {
679
+ instance.projected_version = expectedVersion;
680
+ item.last_error = null;
681
+ }
682
+ else {
683
+ item.last_error = "superseded after delivery; newer projection remains pending";
684
+ }
685
+ });
686
+ }
687
+ markOutbox(outboxId, result) {
688
+ this.mutate((state) => {
689
+ const item = state.outbox.find((candidate) => candidate.outbox_id === outboxId);
690
+ if (!item)
691
+ throw new Error("interaction outbox item not found");
692
+ item.attempts += 1;
693
+ item.updated_at = new Date(this.now()).toISOString();
694
+ item.status = result.delivered ? "delivered" : "pending";
695
+ item.last_error = result.delivered ? null : text(result.error || "delivery failed", "outbox error", 500);
696
+ if (result.delivered && item.kind === "agent_wake") {
697
+ const run = state.runs.find((candidate) => candidate.run_id === item.run_id);
698
+ if (run)
699
+ run.agent_delivery_status = "delivered";
700
+ }
701
+ if (item.kind === "card_projection")
702
+ throw new Error("card projections require completeProjection with an exact version fence");
703
+ });
704
+ }
705
+ }