@awak-app/simy-cli 0.7.19 → 0.7.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -18
- package/package.json +1 -1
- package/src/auto-update.js +17 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +9 -1
- package/src/codex-work-pattern-sync.js +243 -43
- package/src/console/sessions.js +6 -6
- package/src/delivery-hooks/command.js +8 -6
- package/src/delivery-hooks/lifecycle-recovery.js +82 -0
- package/src/delivery-hooks/local-supervisor.js +25 -2
- package/src/delivery-hooks/supervisor-activation.js +25 -0
- package/src/delivery-hooks/supervisor-service.js +2 -2
- package/src/delivery-hooks/supervisor-watchdog.js +11 -6
- package/src/desktop-access.js +1 -1
- package/src/desktop-host.js +40 -11
- package/src/desktop-update-settings.js +15 -0
- package/src/index.js +1 -1
- package/src/session-supervision/claude-sessions.js +7 -7
- package/src/session-supervision/codex-app-server.js +19 -9
- package/src/session-supervision/codex-peer-opinion.js +128 -0
- package/src/session-supervision/index.js +21 -12
- package/src/session-supervision/remote-control.js +27 -0
- package/src/session-supervision/remote-errors.js +22 -0
- package/src/session-supervision/remote-hosts.js +17 -13
- package/src/session-supervision/remote-session-list.js +56 -0
- package/src/session-supervision/session-list.js +83 -23
- package/src/update-detachment/runtime.js +8 -4
package/README.md
CHANGED
|
@@ -248,15 +248,19 @@ Closing the window keeps the agent running. Open the app again or choose
|
|
|
248
248
|
**Show SIMY** from its menu bar icon to return. **Quit SIMY** asks before
|
|
249
249
|
interrupting active tasks. The normal `simy` command remains available.
|
|
250
250
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
251
|
+
The macOS **Automatic updates** switch saves this Mac's choice across restarts.
|
|
252
|
+
Compatible signed installations enable it by default. Patch/minor updates wait
|
|
253
|
+
for idle and verify the new runtime before handoff; failed startup restores the
|
|
254
|
+
previous installed version. The app then switches to the verified new interface
|
|
255
|
+
when no unfinished work or confirmation is pending. Turning the switch off keeps
|
|
256
|
+
manual checks and **Download DMG** available. An explicit `SIMY_AUTO_UPDATE=0`
|
|
257
|
+
remains authoritative, and major updates continue to use the DMG.
|
|
258
|
+
**Stop task…** in a task
|
|
255
259
|
detail asks for confirmation and terminates its local Provider process, keeping
|
|
256
260
|
existing output and history. An explicit update can likewise stop active work
|
|
257
261
|
after confirmation; automatic updates still wait for idle. **⌘Q** exits SIMY and
|
|
258
|
-
its background services directly, including when work is running. The window
|
|
259
|
-
|
|
262
|
+
its background services directly, including when work is running. The window shows
|
|
263
|
+
the app version, and normal app launches select the newer installed interface.
|
|
260
264
|
|
|
261
265
|
Older installers that predate native app relaunch support need a one-time
|
|
262
266
|
reinstallation from the official download page to update their Applications
|
|
@@ -337,8 +341,10 @@ seven days. Usage counters alone leave work time, idle time and utilization
|
|
|
337
341
|
unavailable; a single observed activity event is a measured zero-duration sample.
|
|
338
342
|
Missing usage records remain distinct from recorded zero tokens.
|
|
339
343
|
|
|
340
|
-
The desktop Sessions page loads
|
|
341
|
-
|
|
344
|
+
The desktop Sessions page loads the full available Codex and Claude Code
|
|
345
|
+
conversation catalog without a 200-conversation display limit. Live process
|
|
346
|
+
probes remain limited to recent conversations and explicitly retained work;
|
|
347
|
+
older rows remain visible with unverified execution state. The macOS work list then prioritizes verified running
|
|
342
348
|
conversations, including families with running children, followed by work needing
|
|
343
349
|
a human response and recent activity. Its All, Running, Needs attention, Completed
|
|
344
350
|
and Archived views combine with search. Tracking and auto-continue settings never
|
|
@@ -358,13 +364,15 @@ Codex candidates come from CLI, VS Code and app-server
|
|
|
358
364
|
conversation sources; non-interactive exec, unknown sources and delegated agents
|
|
359
365
|
are excluded. A hook observation alone cannot restore a filtered conversation.
|
|
360
366
|
Explicitly tracked, monitored, auto-continue and unsettled supervision records
|
|
361
|
-
remain visible even
|
|
367
|
+
remain visible even during a provider discovery failure. When a retained Codex conversation is
|
|
362
368
|
omitted from the recent provider page, SIMY reads its exact metadata without
|
|
363
369
|
loading turns or taking writer ownership. The same interactive source filter and
|
|
364
370
|
a matching active rollout path are required before restoring provider capabilities
|
|
365
371
|
and probing its latest turn. Exact reads and turn probes share a bounded
|
|
366
372
|
observation deadline. Claude discovery
|
|
367
|
-
|
|
373
|
+
reads metadata with a bounded I/O pool and bounded transcript prefixes. Full
|
|
374
|
+
catalog reads include every project and transcript, while exact conversation
|
|
375
|
+
content and statistics remain separate reads. A conflicting saved
|
|
368
376
|
workspace leaves only that conversation's Action association unavailable; it
|
|
369
377
|
does not stop discovery or change the saved identity. The page lets users
|
|
370
378
|
choose monitoring or supported session supervision with a goal, acceptance
|
|
@@ -1300,9 +1308,9 @@ original machine. See [setup, access requirements and recovery](docs/remote-code
|
|
|
1300
1308
|
|
|
1301
1309
|
### Local conversation list on NVIDIA / Linux
|
|
1302
1310
|
|
|
1303
|
-
Run `simy sessions`
|
|
1304
|
-
|
|
1305
|
-
updater, analysis worker or a Delivery Loop.
|
|
1311
|
+
Run `simy sessions` to list local and explicitly registered SSH hosts. It reads
|
|
1312
|
+
both providers' actual conversation metadata without starting the agent,
|
|
1313
|
+
updater, analysis worker or a Delivery Loop. Remote reads use SSH with the existing user identity and verified host key.
|
|
1306
1314
|
|
|
1307
1315
|
```sh
|
|
1308
1316
|
simy sessions --query NVIDIA
|
|
@@ -1311,12 +1319,28 @@ simy sessions --json
|
|
|
1311
1319
|
```
|
|
1312
1320
|
|
|
1313
1321
|
In the interactive list, use Up/Down to select a conversation, `/` to search its
|
|
1314
|
-
title, folder or ID, Enter to finish searching, Esc to clear the filter, `r` to
|
|
1322
|
+
title, folder, provider or ID, Enter to finish searching, Esc to clear the filter, `r` to
|
|
1315
1323
|
refresh and `q` to quit. The selected conversation shows its exact ID and folder.
|
|
1316
|
-
Search covers the
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1324
|
+
Search covers the full available catalog, including older conversations. Use
|
|
1325
|
+
`--include-archived` to include archived conversations from both providers.
|
|
1326
|
+
Each row identifies its provider; identical IDs in Codex and Claude remain
|
|
1327
|
+
separate rows. A provider failure preserves the other provider's results with
|
|
1328
|
+
an explicit incomplete warning (`complete: false` and `warnings` in JSON).
|
|
1329
|
+
This view respects both saved provider connection settings and never enables
|
|
1330
|
+
automatic continuation. Disconnected providers are not read.
|
|
1331
|
+
|
|
1332
|
+
The terminal command reads this host and hosts explicitly registered in
|
|
1333
|
+
`~/.simy/remote-hosts.json`. Use `--local-only` to skip SSH, and
|
|
1334
|
+
`--provider codex` or `--provider claude` to select a provider. Remote SIMY must
|
|
1335
|
+
also include these options; older hosts produce an incomplete warning instead
|
|
1336
|
+
of silently disappearing. Each row retains its host, provider and conversation
|
|
1337
|
+
ID; search also matches the host label. SSH reads run `simy sessions --local-only`
|
|
1338
|
+
and do not start a desktop host, supervisor, or provider turn. Both the local
|
|
1339
|
+
and remote provider connection settings apply.
|
|
1340
|
+
|
|
1341
|
+
The desktop bridge also includes both providers. Neither interface imports
|
|
1342
|
+
Codex desktop connection settings automatically or claims to discover
|
|
1343
|
+
unregistered hosts, another OS user's history, or malformed/unsupported records.
|
|
1320
1344
|
|
|
1321
1345
|
The existing per-provider heartbeat now includes `device_io` with
|
|
1322
1346
|
`schema_version: local_device_io.v1`. It distinguishes Codex verified native-cwd
|
|
@@ -1346,3 +1370,28 @@ When supervision is already live, install the candidate, request
|
|
|
1346
1370
|
`runtime supervisor-handover --release ABSOLUTE_RELEASE`, and wait for activation.
|
|
1347
1371
|
The watcher preserves the live supervisor while service registration is updated.
|
|
1348
1372
|
No command here enrolls an unrelated conversation or renews an existing budget.
|
|
1373
|
+
|
|
1374
|
+
### Resume after sleep, logout or reboot
|
|
1375
|
+
|
|
1376
|
+
The OS watchdog retains activation in `supervisor-runtime/control.json`. Sleep
|
|
1377
|
+
re-observes the existing process before replacing it. After logout/login or a
|
|
1378
|
+
shutdown/reboot, the registered OS service loads the same runtime and saved task
|
|
1379
|
+
state. Linux uses a systemd user service (user lingering permits running after
|
|
1380
|
+
logout); macOS uses a login LaunchAgent; Windows uses the interactive user's
|
|
1381
|
+
scheduled task. macOS/Windows recovery therefore starts after that user logs in.
|
|
1382
|
+
No work runs while the computer is powered off or asleep.
|
|
1383
|
+
|
|
1384
|
+
To enable durable recovery for one existing opted-in conversation:
|
|
1385
|
+
|
|
1386
|
+
```sh
|
|
1387
|
+
~/.simy/bin/simy delivery runtime supervisor-enable --provider codex --session SESSION_ID
|
|
1388
|
+
~/.simy/bin/simy delivery runtime supervisor-watchdog-install
|
|
1389
|
+
```
|
|
1390
|
+
|
|
1391
|
+
This selection binds its existing run and consent epoch, excluding other tasks.
|
|
1392
|
+
The recovered host starts by reading saved results and current artifacts. It
|
|
1393
|
+
retains the goal, budgets and unresolved effects; it does not replay an
|
|
1394
|
+
interrupted write or approve a pending external action. Explicit task
|
|
1395
|
+
stop/pause, provider permission prompts and unanswered user questions remain
|
|
1396
|
+
boundaries. `runtime supervisor-disable` is a durable explicit stop and is never
|
|
1397
|
+
reversed by waking or booting. A later explicit enable is required.
|
package/package.json
CHANGED
package/src/auto-update.js
CHANGED
|
@@ -86,6 +86,7 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
86
86
|
install_mode: options.installMode,
|
|
87
87
|
automatic: Boolean(
|
|
88
88
|
!options.disabled &&
|
|
89
|
+
options.autoUpdatesEnabled !== false &&
|
|
89
90
|
options.daemon &&
|
|
90
91
|
(options.installMode === "global_npm" ||
|
|
91
92
|
(options.installMode === MACOS_INSTALLER_MODE && options.installerMarker?.teamId)) &&
|
|
@@ -101,7 +102,20 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
snapshot() {
|
|
104
|
-
|
|
105
|
+
const supported = !this.#options.disabled && this.#options.daemon && this.#options.requestedPort === 0 &&
|
|
106
|
+
(this.#options.installMode === 'global_npm' || this.#options.installMode === MACOS_INSTALLER_MODE && Boolean(this.#options.installerMarker?.teamId));
|
|
107
|
+
return structuredClone({ ...this.#snapshot, auto_update_supported: Boolean(supported),
|
|
108
|
+
auto_update_enabled: this.#snapshot.automatic, auto_update_busy: Boolean(this.#applying || this.#confirmedUpdate), requires_task_stop: !this.#isIdle() });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setAutomaticUpdates(enabled) {
|
|
112
|
+
if (typeof enabled !== 'boolean') throw new Error('Choose whether automatic updates are enabled.');
|
|
113
|
+
if (enabled && !this.snapshot().auto_update_supported) throw new Error('Automatic updates are unavailable for this installation.');
|
|
114
|
+
if (this.#applying || this.#confirmedUpdate) throw new Error('Wait for the current update to finish.');
|
|
115
|
+
this.#options.autoUpdatesEnabled = enabled;
|
|
116
|
+
if (!enabled) this.#clearIdleTimer();
|
|
117
|
+
this.#setState(!enabled && this.#snapshot.state === 'waiting_for_idle' ? 'update_available' : this.#snapshot.state,
|
|
118
|
+
{ automatic: enabled, error: null });
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
// Close admission before cancellation begins. The same transaction owns the
|
|
@@ -435,6 +449,7 @@ export async function createCliAutoUpdater({
|
|
|
435
449
|
interactive,
|
|
436
450
|
requestedPort = 0,
|
|
437
451
|
disabled = false,
|
|
452
|
+
autoUpdatesEnabled = true,
|
|
438
453
|
packageRoot = PACKAGE_ROOT,
|
|
439
454
|
webOrigin,
|
|
440
455
|
dependencies = {},
|
|
@@ -458,6 +473,7 @@ export async function createCliAutoUpdater({
|
|
|
458
473
|
interactive: Boolean(interactive),
|
|
459
474
|
requestedPort,
|
|
460
475
|
disabled,
|
|
476
|
+
autoUpdatesEnabled,
|
|
461
477
|
currentVersion: packageInfo.version,
|
|
462
478
|
installMode,
|
|
463
479
|
installerMarker,
|
package/src/cli-contract.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "./durable-local-task-steps-contract.js";
|
|
15
15
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
16
16
|
|
|
17
|
-
export const CLI_VERSION = "0.7.
|
|
17
|
+
export const CLI_VERSION = "0.7.21";
|
|
18
18
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
19
19
|
|
|
20
20
|
export function narrativeImageCapabilityMetadata() {
|
|
@@ -824,7 +824,7 @@ function hookDeliveryContext(record, automatic = false) {
|
|
|
824
824
|
`Preserve the current task and its permissions, pauses and stops; never restart or reset it automatically.`;
|
|
825
825
|
}
|
|
826
826
|
export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), executionCwd, upgradeRunId, previousRunId, reconcileRunId, objective, nextStep, maxMinutes, maxContinuations, maxFailures, continuationPolicy, managed, admission,
|
|
827
|
-
liveHookAdmission, autoContinueEpoch, providerLinkEpoch, inspectProcess = processIdentity, now = Date.now() }) {
|
|
827
|
+
liveHookAdmission, autoContinueEpoch, followupAutoContinue = false, providerLinkEpoch, inspectProcess = processIdentity, now = Date.now() }) {
|
|
828
828
|
const link = await providerLinkState(root, provider);
|
|
829
829
|
if (providerLinkEpoch !== undefined && providerLinkEpoch !== link.epoch) throw Object.assign(new Error("SIMY provider connection changed before enrollment."), { code: "provider_link_changed" });
|
|
830
830
|
if (!link.connected) throw Object.assign(new Error("Reconnect this provider to SIMY before using it."), { code: "provider_link_disconnected" });
|
|
@@ -871,6 +871,7 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
871
871
|
if ((following || reconciling) && (provider !== 'codex' || !admitted || !managedState?.autoRecovery || managedState.capability !== 'codex_app_server' || managedState.initialAction.kind !== 'read' || managedState.contextBootstrap)) throw new Error('Follow-up requires exact-session managed supervision, a concrete goal and read-only reconciliation.');
|
|
872
872
|
if (upgrading && (!managedState?.autonomyPolicy || managedState.initialAction.kind !== 'read' || managedState.contextBootstrap)) throw new Error('Upgrade requires read-only reconciliation and frozen acceptance of the original objective.');
|
|
873
873
|
if (!managedState && continuationMode(continuationPolicy) === 'progress') throw new Error('Progress continuation requires frozen managed acceptance criteria.');
|
|
874
|
+
if (followupAutoContinue && !(following || reconciling)) throw new Error('Auto-continue follow-up requires the exact finished run.');
|
|
874
875
|
if (autoContinueEpoch && managedState) managedState.autoContinueEpoch = autoContinueEpoch;
|
|
875
876
|
if (managedState && previous.associationTaskId) {
|
|
876
877
|
managedState.taskId = previous.associationTaskId;
|
|
@@ -916,6 +917,13 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
916
917
|
if (saved && digest(saved) !== digest(archive)) throw new Error('Finished run archive differs; reconcile its history before enrolling follow-up work.');
|
|
917
918
|
if (!saved) atomicSave(file, archive);
|
|
918
919
|
}
|
|
920
|
+
// The explicit follow-up dialog enrolls and saves ON in one transaction.
|
|
921
|
+
// A failed/stale request never leaves a half-enabled preference behind.
|
|
922
|
+
if (followupAutoContinue) {
|
|
923
|
+
const epoch = randomUUID();
|
|
924
|
+
managedState.autoContinueEpoch = epoch;
|
|
925
|
+
return { ...previous, autoContinue: true, autoContinueEpoch: epoch, run };
|
|
926
|
+
}
|
|
919
927
|
return { ...previous, run };
|
|
920
928
|
});
|
|
921
929
|
}
|
|
@@ -13,9 +13,11 @@ const MAX_MESSAGES = 24;
|
|
|
13
13
|
const MAX_MESSAGE_CHARS = 1_500;
|
|
14
14
|
const MAX_CANDIDATE_FILES = 200;
|
|
15
15
|
export const MAX_GATEWAY_BODY_BYTES = 512 * 1024;
|
|
16
|
+
const MAX_STATUS_RECEIPTS = 10;
|
|
17
|
+
const MAX_STATUS_BODY_BYTES = 12 * 1024;
|
|
16
18
|
const DEFAULT_LOOKBACK_DAYS = 90;
|
|
17
19
|
const POLL_INTERVAL_MS = 15 * 60 * 1_000;
|
|
18
|
-
const STATE_SCHEMA_VERSION = "work-pattern-history-sync.
|
|
20
|
+
const STATE_SCHEMA_VERSION = "work-pattern-history-sync.v7";
|
|
19
21
|
export const HISTORY_EXTRACTION_POLICY = "user_conversation.v2";
|
|
20
22
|
const PROVIDER_CODEX = "codex";
|
|
21
23
|
const PROVIDER_CLAUDE = "claude";
|
|
@@ -125,7 +127,20 @@ export async function syncCodexWorkPatterns({
|
|
|
125
127
|
deviceId,
|
|
126
128
|
});
|
|
127
129
|
const persistedState = await readSyncState(statePath);
|
|
128
|
-
const
|
|
130
|
+
const pendingReceipts = pendingReceiptMetadata(persistedState.pending_receipts);
|
|
131
|
+
if (pendingReceipts.length > 0) {
|
|
132
|
+
const pendingResult = await pollTimedOutReceipt({
|
|
133
|
+
endpoint,
|
|
134
|
+
token,
|
|
135
|
+
receipts: pendingReceipts,
|
|
136
|
+
fetchImpl,
|
|
137
|
+
signal,
|
|
138
|
+
authorizeProviders,
|
|
139
|
+
});
|
|
140
|
+
if (pendingResult.pending) return { status: "pending", uploaded: 0 };
|
|
141
|
+
}
|
|
142
|
+
const { pending_receipts: _pendingReceipts, ...resolvedPersistedState } = persistedState;
|
|
143
|
+
const previousState = statusPayload.reset_cursor === true ? {} : resolvedPersistedState;
|
|
129
144
|
const state = { ...previousState, providers: Object.fromEntries(Object.entries(previousState.providers || {})
|
|
130
145
|
.filter(([, cursor]) => cursor?.reset_pending !== true && (cursor?.lookback_days ?? previousState.lookback_days) === lookbackDays)) };
|
|
131
146
|
const lookbackCutoffMs = now - lookbackDays * 24 * 60 * 60 * 1_000;
|
|
@@ -175,10 +190,12 @@ export async function syncCodexWorkPatterns({
|
|
|
175
190
|
}))).flat();
|
|
176
191
|
const selectedFiles = selectSessionFiles(files);
|
|
177
192
|
const threads = [];
|
|
178
|
-
const acknowledgedRevisions = new Set(
|
|
193
|
+
const acknowledgedRevisions = new Set([
|
|
194
|
+
...providerSpecs.flatMap((spec) =>
|
|
179
195
|
Object.values(providerCursor(state, spec.provider).processed_files)
|
|
180
196
|
.filter((file) => file.lineage_ref && file.revision_ref)
|
|
181
|
-
.map((file) => `${file.lineage_ref}\0${file.revision_ref}`))
|
|
197
|
+
.map((file) => `${file.lineage_ref}\0${file.revision_ref}`)),
|
|
198
|
+
]);
|
|
182
199
|
const successfullyReadFiles = [];
|
|
183
200
|
const unreadableFiles = [];
|
|
184
201
|
for (const file of selectedFiles) {
|
|
@@ -212,6 +229,7 @@ export async function syncCodexWorkPatterns({
|
|
|
212
229
|
if (
|
|
213
230
|
chunk.messages.length > 0
|
|
214
231
|
&& Date.parse(chunk.observed_at) >= lookbackCutoffMs
|
|
232
|
+
&& !pendingReceipts.some(receipt => receiptKey(receipt) === threadReceiptKey(chunk))
|
|
215
233
|
) {
|
|
216
234
|
threads.push(chunk);
|
|
217
235
|
}
|
|
@@ -228,9 +246,38 @@ export async function syncCodexWorkPatterns({
|
|
|
228
246
|
return { status: "up_to_date", uploaded: 0 };
|
|
229
247
|
}
|
|
230
248
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
249
|
+
let uploadResult = { uploaded: 0 };
|
|
250
|
+
if (threads.length > 0) {
|
|
251
|
+
// Persist only opaque receipt metadata before an admission request that
|
|
252
|
+
// could reach Crew after this process loses its response. A restart polls
|
|
253
|
+
// these tuples and never reconstructs a plaintext retry from them.
|
|
254
|
+
let attempted = [...pendingReceipts];
|
|
255
|
+
const recordAttempt = async (batch, remove = false) => {
|
|
256
|
+
const keys = new Set(batch.map(threadReceiptKey));
|
|
257
|
+
attempted = attempted.filter(receipt => !keys.has(receiptKey(receipt)));
|
|
258
|
+
if (!remove) attempted.push(...batch.map(receiptMetadata));
|
|
259
|
+
if (attempted.length) {
|
|
260
|
+
await writePendingReceiptState({ statePath, lookbackDays, providers: previousState.providers || {}, receipts: attempted, now });
|
|
261
|
+
} else {
|
|
262
|
+
await clearPendingReceiptState({ statePath, lookbackDays, providers: previousState.providers || {}, now });
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
uploadResult = await uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal, authorizeProviders, recordAttempt });
|
|
266
|
+
if (uploadResult.pending_receipts?.length > 0) uploadResult.pending_receipts = attempted;
|
|
267
|
+
}
|
|
268
|
+
if (uploadResult.pending_receipts?.length > 0) {
|
|
269
|
+
await writePendingReceiptState({
|
|
270
|
+
statePath,
|
|
271
|
+
lookbackDays,
|
|
272
|
+
providers: previousState.providers || {},
|
|
273
|
+
receipts: uploadResult.pending_receipts,
|
|
274
|
+
now,
|
|
275
|
+
});
|
|
276
|
+
return { status: "pending", uploaded: 0 };
|
|
277
|
+
}
|
|
278
|
+
if (threads.length > 0 && uploadResult.uploaded !== threads.length) {
|
|
279
|
+
return { status: "pending", uploaded: uploadResult.uploaded };
|
|
280
|
+
}
|
|
234
281
|
if (shouldAcknowledgeEmptyReset) {
|
|
235
282
|
const { payload } = await postThreadBatch({ endpoint, token, threads: [], fetchImpl, signal, authorizeProviders });
|
|
236
283
|
validateThreadBatchAcknowledgement(payload, []);
|
|
@@ -279,7 +326,7 @@ export function workPatternSyncStatePath({ sessionRoot, apiOrigin, accountId, de
|
|
|
279
326
|
return join(sessionRoot, "work-pattern-history-sync", `${scopeHash}.json`);
|
|
280
327
|
}
|
|
281
328
|
|
|
282
|
-
async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal, authorizeProviders }) {
|
|
329
|
+
async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal, authorizeProviders, recordAttempt }) {
|
|
283
330
|
const batches = [];
|
|
284
331
|
let batch = [];
|
|
285
332
|
for (const thread of threads) {
|
|
@@ -299,6 +346,7 @@ async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal
|
|
|
299
346
|
if (batch.length > 0) batches.push(batch);
|
|
300
347
|
|
|
301
348
|
let uploaded = 0;
|
|
349
|
+
const pendingReceipts = [];
|
|
302
350
|
for (const current of batches) {
|
|
303
351
|
const result = await postBatchWithBounded413Recovery({
|
|
304
352
|
endpoint,
|
|
@@ -307,26 +355,45 @@ async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal
|
|
|
307
355
|
fetchImpl,
|
|
308
356
|
signal,
|
|
309
357
|
authorizeProviders,
|
|
358
|
+
recordAttempt,
|
|
310
359
|
});
|
|
311
360
|
uploaded += result.uploaded;
|
|
361
|
+
if (result.pending_receipts?.length > 0) {
|
|
362
|
+
pendingReceipts.push(...result.pending_receipts);
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
312
365
|
}
|
|
313
|
-
return
|
|
366
|
+
return pendingReceipts.length > 0
|
|
367
|
+
? { uploaded, pending_receipts: pendingReceipts }
|
|
368
|
+
: { uploaded };
|
|
314
369
|
}
|
|
315
370
|
|
|
316
|
-
async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetchImpl, signal, authorizeProviders }) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
371
|
+
async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetchImpl, signal, authorizeProviders, recordAttempt }) {
|
|
372
|
+
await recordAttempt(threads);
|
|
373
|
+
let response, payload;
|
|
374
|
+
try {
|
|
375
|
+
({ response, payload } = await postThreadBatch({
|
|
376
|
+
endpoint,
|
|
377
|
+
token,
|
|
378
|
+
threads,
|
|
379
|
+
fetchImpl,
|
|
380
|
+
signal,
|
|
381
|
+
allow413: true,
|
|
382
|
+
authorizeProviders,
|
|
383
|
+
}));
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (error?.definitePreAdmission === true) await recordAttempt(threads, true);
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
326
388
|
if (response.status !== 413) {
|
|
327
|
-
|
|
389
|
+
const partition = validateReceiptPartition(payload, threads.map(receiptMetadata));
|
|
390
|
+
if (partition.pending > 0 || payload?._receiptPending === true) {
|
|
391
|
+
return { uploaded: 0, pending_receipts: threads.map(receiptMetadata) };
|
|
392
|
+
}
|
|
393
|
+
if (partition.notFound > 0) throwIncompleteAcknowledgement();
|
|
328
394
|
return { uploaded: threads.length };
|
|
329
395
|
}
|
|
396
|
+
await recordAttempt(threads, true);
|
|
330
397
|
if (threads.length > 1) {
|
|
331
398
|
const middle = Math.ceil(threads.length / 2);
|
|
332
399
|
const left = await postBatchWithBounded413Recovery({
|
|
@@ -336,7 +403,9 @@ async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetch
|
|
|
336
403
|
fetchImpl,
|
|
337
404
|
signal,
|
|
338
405
|
authorizeProviders,
|
|
406
|
+
recordAttempt,
|
|
339
407
|
});
|
|
408
|
+
if (left.pending_receipts?.length) return left;
|
|
340
409
|
const right = await postBatchWithBounded413Recovery({
|
|
341
410
|
endpoint,
|
|
342
411
|
token,
|
|
@@ -344,13 +413,19 @@ async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetch
|
|
|
344
413
|
fetchImpl,
|
|
345
414
|
signal,
|
|
346
415
|
authorizeProviders,
|
|
416
|
+
recordAttempt,
|
|
347
417
|
});
|
|
348
|
-
return {
|
|
418
|
+
return {
|
|
419
|
+
uploaded: left.uploaded + right.uploaded,
|
|
420
|
+
pending_receipts: [...(left.pending_receipts || []), ...(right.pending_receipts || [])],
|
|
421
|
+
};
|
|
349
422
|
}
|
|
350
|
-
|
|
423
|
+
const error = new Error(
|
|
351
424
|
"Work-pattern observation chunk was rejected as too large; "
|
|
352
425
|
+ "the complete revision was not acknowledged and will be retried unchanged.",
|
|
353
426
|
);
|
|
427
|
+
error.definitePreAdmission = true;
|
|
428
|
+
throw error;
|
|
354
429
|
}
|
|
355
430
|
|
|
356
431
|
async function postThreadBatch({
|
|
@@ -366,46 +441,171 @@ async function postThreadBatch({
|
|
|
366
441
|
if (Buffer.byteLength(body, "utf8") > MAX_GATEWAY_BODY_BYTES) {
|
|
367
442
|
throw new Error("Work-pattern observation payload exceeds the local CLI gateway limit.");
|
|
368
443
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
444
|
+
try {
|
|
445
|
+
await authorizeProviders();
|
|
446
|
+
} catch (error) {
|
|
447
|
+
error.definitePreAdmission = true;
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
let response;
|
|
451
|
+
try {
|
|
452
|
+
response = await fetchImpl(endpoint, {
|
|
453
|
+
method: "POST",
|
|
454
|
+
headers: webApiHeaders({ token }, { "Content-Type": "application/json" }),
|
|
455
|
+
body,
|
|
456
|
+
signal: signalWithTimeout(signal, 120_000),
|
|
457
|
+
});
|
|
458
|
+
} catch (error) {
|
|
459
|
+
if (signal?.aborted || !isTimeout(error)) throw error;
|
|
460
|
+
const polled = await pollTimedOutReceipt({ endpoint, token, threads, fetchImpl, signal, authorizeProviders });
|
|
461
|
+
return polled.pending
|
|
462
|
+
? { response: polled.response, payload: { ...polled.payload, _receiptPending: true } }
|
|
463
|
+
: polled;
|
|
464
|
+
}
|
|
376
465
|
const payload = await response.json().catch(() => null);
|
|
377
466
|
if (!response.ok && !(allow413 && response.status === 413)) {
|
|
378
|
-
|
|
467
|
+
if (response.status >= 500 || response.status === 408 || response.status === 429) {
|
|
468
|
+
const polled = await pollTimedOutReceipt({ endpoint, token, threads, fetchImpl, signal, authorizeProviders });
|
|
469
|
+
return polled.pending
|
|
470
|
+
? { response: polled.response, payload: { ...polled.payload, _receiptPending: true } }
|
|
471
|
+
: polled;
|
|
472
|
+
}
|
|
473
|
+
const error = new Error(webApiErrorMessage(payload, response.status));
|
|
474
|
+
error.definitePreAdmission = response.status >= 400 && response.status < 500 && response.status !== 409;
|
|
475
|
+
throw error;
|
|
379
476
|
}
|
|
380
477
|
return { response, payload };
|
|
381
478
|
}
|
|
382
479
|
|
|
480
|
+
function isTimeout(error) {
|
|
481
|
+
return error?.name === "TimeoutError" || error?.name === "AbortError";
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function pollTimedOutReceipt({ endpoint, token, threads, receipts, fetchImpl, signal, authorizeProviders }) {
|
|
485
|
+
const expectedReceipts = receipts || threads.map(receiptMetadata);
|
|
486
|
+
const batches = receiptBatches(expectedReceipts);
|
|
487
|
+
await authorizeProviders();
|
|
488
|
+
const responses = [];
|
|
489
|
+
let hasPending = false;
|
|
490
|
+
for (const batch of batches) {
|
|
491
|
+
const response = await fetchImpl(new URL("status/", endpoint), {
|
|
492
|
+
method: "POST",
|
|
493
|
+
headers: webApiHeaders({ token }, { "Content-Type": "application/json" }),
|
|
494
|
+
body: JSON.stringify({ receipts: batch }),
|
|
495
|
+
signal: signalWithTimeout(signal, 5_000),
|
|
496
|
+
});
|
|
497
|
+
const payload = await response.json().catch(() => null);
|
|
498
|
+
if (!response.ok) throw new Error(webApiErrorMessage(payload, response.status));
|
|
499
|
+
const partition = validateReceiptPartition(payload, batch);
|
|
500
|
+
if (partition.notFound > 0) throwIncompleteAcknowledgement();
|
|
501
|
+
hasPending ||= partition.pending > 0;
|
|
502
|
+
responses.push({ response, payload });
|
|
503
|
+
}
|
|
504
|
+
const last = responses.at(-1);
|
|
505
|
+
return hasPending
|
|
506
|
+
? { response: last.response, payload: last.payload, pending: true }
|
|
507
|
+
: { response: last.response, payload: last.payload };
|
|
508
|
+
}
|
|
509
|
+
|
|
383
510
|
function validateThreadBatchAcknowledgement(payload, threads) {
|
|
511
|
+
const partition = validateReceiptPartition(payload, threads.map(receiptMetadata));
|
|
512
|
+
if (partition.pending > 0 || partition.notFound > 0) throwIncompleteAcknowledgement();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function validateReceiptPartition(payload, expectedReceipts) {
|
|
516
|
+
const accepted = Array.isArray(payload?.accepted_receipts) ? payload.accepted_receipts : [];
|
|
517
|
+
const pending = Array.isArray(payload?.pending_receipts) ? payload.pending_receipts : [];
|
|
518
|
+
const notFound = Array.isArray(payload?.not_found_receipts) ? payload.not_found_receipts : [];
|
|
384
519
|
if (
|
|
385
|
-
payload?.raw_content_persisted !== false
|
|
386
|
-
!Number.isInteger(payload?.accepted)
|
|
387
|
-
payload.accepted !==
|
|
388
|
-
!
|
|
389
|
-
payload.
|
|
390
|
-
)
|
|
520
|
+
payload?.raw_content_persisted !== false
|
|
521
|
+
|| !Number.isInteger(payload?.accepted)
|
|
522
|
+
|| payload.accepted !== accepted.length
|
|
523
|
+
|| (payload?.pending !== undefined && (!Number.isInteger(payload.pending) || payload.pending !== pending.length))
|
|
524
|
+
|| (payload?.not_found !== undefined && (!Number.isInteger(payload.not_found) || payload.not_found !== notFound.length))
|
|
525
|
+
) throwIncompleteAcknowledgement();
|
|
526
|
+
const expected = expectedReceipts.map(receiptKey).sort();
|
|
527
|
+
const actual = [...accepted, ...pending, ...notFound].map(receiptKey).sort();
|
|
528
|
+
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
|
391
529
|
throwIncompleteAcknowledgement();
|
|
392
530
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
for (let index = 0; index < expected.length; index += 1) {
|
|
396
|
-
if (actual[index] !== expected[index]) throwIncompleteAcknowledgement();
|
|
397
|
-
}
|
|
531
|
+
if (new Set(actual).size !== actual.length) throwIncompleteAcknowledgement();
|
|
532
|
+
return { accepted: accepted.length, pending: pending.length, notFound: notFound.length };
|
|
398
533
|
}
|
|
399
534
|
|
|
400
535
|
function threadReceiptKey(thread) {
|
|
401
|
-
return receiptKey(
|
|
536
|
+
return receiptKey(receiptMetadata(thread));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function receiptMetadata(thread) {
|
|
540
|
+
return {
|
|
402
541
|
source_ref: thread.source_ref,
|
|
403
542
|
revision_ref: thread.revision_ref,
|
|
404
543
|
lineage_ref: thread.lineage_ref,
|
|
405
544
|
chunk_index: thread.chunk_index,
|
|
406
545
|
chunk_count: thread.chunk_count,
|
|
407
546
|
message_offset: thread.message_offset,
|
|
408
|
-
message_count: Array.isArray(thread.messages)
|
|
547
|
+
message_count: Array.isArray(thread.messages)
|
|
548
|
+
? thread.messages.length
|
|
549
|
+
: (Number.isInteger(thread.message_count) ? thread.message_count : -1),
|
|
550
|
+
extraction_policy: thread.extraction_policy,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function pendingReceiptMetadata(receipts) {
|
|
555
|
+
if (receipts === undefined) return [];
|
|
556
|
+
if (!Array.isArray(receipts)) {
|
|
557
|
+
throw new Error("Pending work-pattern receipt state is invalid; history was not resent.");
|
|
558
|
+
}
|
|
559
|
+
return receipts.map((receipt) => {
|
|
560
|
+
if (
|
|
561
|
+
!/^(?:codex|claude):[0-9a-f]{64}$/.test(receipt?.source_ref || "")
|
|
562
|
+
|| !/^(?:codex|claude):[0-9a-f]{64}$/.test(receipt?.lineage_ref || "")
|
|
563
|
+
|| !/^[0-9a-f]{64}$/.test(receipt?.revision_ref || "")
|
|
564
|
+
|| receipt?.extraction_policy !== HISTORY_EXTRACTION_POLICY
|
|
565
|
+
|| !Number.isInteger(receipt?.chunk_index)
|
|
566
|
+
|| !Number.isInteger(receipt?.chunk_count)
|
|
567
|
+
|| !Number.isInteger(receipt?.message_offset)
|
|
568
|
+
|| !Number.isInteger(receipt?.message_count)
|
|
569
|
+
) {
|
|
570
|
+
throw new Error("Pending work-pattern receipt state is invalid; history was not resent.");
|
|
571
|
+
}
|
|
572
|
+
return receipt;
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function receiptBatches(receipts) {
|
|
577
|
+
const batches = [];
|
|
578
|
+
let batch = [];
|
|
579
|
+
for (const receipt of receipts) {
|
|
580
|
+
const candidate = [...batch, receipt];
|
|
581
|
+
if (candidate.length > MAX_STATUS_RECEIPTS || Buffer.byteLength(JSON.stringify({ receipts: candidate }), "utf8") > MAX_STATUS_BODY_BYTES) {
|
|
582
|
+
if (batch.length === 0) throw new Error("Work-pattern receipt metadata exceeds the local gateway limit.");
|
|
583
|
+
batches.push(batch);
|
|
584
|
+
batch = [receipt];
|
|
585
|
+
} else {
|
|
586
|
+
batch = candidate;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (batch.length > 0) batches.push(batch);
|
|
590
|
+
return batches;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function writePendingReceiptState({ statePath, lookbackDays, providers, receipts, now }) {
|
|
594
|
+
await writeSyncState(statePath, {
|
|
595
|
+
schema_version: STATE_SCHEMA_VERSION,
|
|
596
|
+
lookback_days: lookbackDays,
|
|
597
|
+
providers,
|
|
598
|
+
pending_receipts: receipts,
|
|
599
|
+
last_synced_at: new Date(now).toISOString(),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function clearPendingReceiptState({ statePath, lookbackDays, providers, now }) {
|
|
604
|
+
await writeSyncState(statePath, {
|
|
605
|
+
schema_version: STATE_SCHEMA_VERSION,
|
|
606
|
+
lookback_days: lookbackDays,
|
|
607
|
+
providers,
|
|
608
|
+
last_synced_at: new Date(now).toISOString(),
|
|
409
609
|
});
|
|
410
610
|
}
|
|
411
611
|
|