@awak-app/simy-cli 0.7.21 → 0.7.23
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 +47 -17
- package/package.json +3 -3
- package/src/agent.js +28 -8
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +3 -1
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +1 -1
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +89 -6
- package/src/coding-run-automatic-recovery.js +40 -0
- package/src/console/app.js +5 -0
- package/src/delivery-hooks/command.js +12 -0
- package/src/delivery-hooks/supervisor.js +14 -4
- package/src/desktop-access.js +1 -1
- package/src/desktop-clipboard.js +23 -0
- package/src/desktop-host.js +76 -9
- package/src/orchestrator/audit.js +2 -2
- package/src/orchestrator/independent-audit.js +3 -3
- package/src/orchestrator/instruction.js +1 -0
- package/src/orchestrator/loop.js +19 -12
- package/src/orchestrator/recovery.js +12 -7
- package/src/orchestrator/retry.js +7 -1
- package/src/provider-links.js +14 -4
- package/src/runner.js +24 -0
- package/src/sqm/agent-skill.js +4 -2
- package/src/sqm/failure-circuit.js +39 -7
- package/src/sqm/provider-command.js +1 -1
- package/src/sqm/provider-plugin-repair.js +105 -0
- package/src/sqm/provider-plugin-status.js +20 -8
- package/src/sqm/review-worker.js +30 -8
- package/src/update-detachment/history-recovery.js +51 -0
- package/src/update-detachment/runtime.js +3 -3
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { mkdir, readFile, realpath, rm } from 'node:fs/promises';
|
|
4
|
+
import { resolveBackendExecutable } from '../backend-executable.js';
|
|
5
|
+
import { inspectProviderPlugins } from './provider-plugin-status.js';
|
|
6
|
+
import { runProviderCommand } from './provider-command.js';
|
|
7
|
+
import { inspectSqmAgentSkill, installSqmAgentSkill } from './agent-skill.js';
|
|
8
|
+
import { CLAUDE_MARKETPLACE, CLAUDE_PLUGIN_SELECTOR } from './claude-plugin.js';
|
|
9
|
+
|
|
10
|
+
const fail = reason => Object.assign(new Error('SIMY plugin repair could not be verified.'), { code: 'plugin_repair_failed', reason });
|
|
11
|
+
|
|
12
|
+
// Explicit user action only. Repair one provider, under the same lock as the
|
|
13
|
+
// background workflow updater. Never change SIMY consent, login or hook trust.
|
|
14
|
+
export async function repairProviderPlugin({ provider, home = homedir(),
|
|
15
|
+
codexHome = process.env.CODEX_HOME || join(home, '.codex'),
|
|
16
|
+
claudeHome = process.env.CLAUDE_CONFIG_DIR || join(home, '.claude'),
|
|
17
|
+
signal, authorize = async () => {}, onDiagnostic = () => {},
|
|
18
|
+
inspect = inspectProviderPlugins, inspectSources = inspectSqmAgentSkill,
|
|
19
|
+
prepare = installSqmAgentSkill, resolveExecutable = resolveBackendExecutable,
|
|
20
|
+
run = runProviderCommand,
|
|
21
|
+
} = {}) {
|
|
22
|
+
if (!['codex', 'claude'].includes(provider)) throw fail('unsupported_provider');
|
|
23
|
+
const startedAt = Date.now();
|
|
24
|
+
const check = async () => {
|
|
25
|
+
if (signal?.aborted) throw fail('cancelled');
|
|
26
|
+
await authorize();
|
|
27
|
+
if (signal?.aborted) throw fail('cancelled');
|
|
28
|
+
};
|
|
29
|
+
const options = { home, codexHome, claudeHome, providers: [provider], details: true,
|
|
30
|
+
timeoutMs: 20_000, signal, onDiagnostic, resolveExecutable, run };
|
|
31
|
+
const inventory = async () => (await inspect(options))[provider];
|
|
32
|
+
const command = async args => {
|
|
33
|
+
await check();
|
|
34
|
+
const executable = await resolveExecutable(provider, { signal, timeoutMs: 5_000 });
|
|
35
|
+
if (!executable) throw fail('cli_missing');
|
|
36
|
+
return run(executable, args, { cwd: home, signal, timeout: 20_000, maxBuffer: 1024 * 1024,
|
|
37
|
+
env: { ...process.env, HOME: home, ...(provider === 'codex' ? { CODEX_HOME: codexHome } : { CLAUDE_CONFIG_DIR: claudeHome }) } });
|
|
38
|
+
};
|
|
39
|
+
let locked = false;
|
|
40
|
+
const lock = join(home, '.simy', 'integrations', 'workflow-update.lock');
|
|
41
|
+
try {
|
|
42
|
+
await check();
|
|
43
|
+
let current = await inventory();
|
|
44
|
+
if (current?.state === 'enabled') return { state: 'repaired', plugin: current };
|
|
45
|
+
if (!['missing', 'disabled'].includes(current?.state)) throw fail(current?.reason || 'verification_failed');
|
|
46
|
+
if (provider === 'claude' && current.scope && current.scope !== 'user') throw fail('scope_requires_attention');
|
|
47
|
+
await mkdir(join(home, '.simy', 'integrations'), { recursive: true });
|
|
48
|
+
try { await mkdir(lock); locked = true; }
|
|
49
|
+
catch (error) { if (error.code === 'EEXIST') throw fail('busy'); throw error; }
|
|
50
|
+
await check();
|
|
51
|
+
// Reinspect after obtaining the lock; another updater may have finished.
|
|
52
|
+
current = await inventory();
|
|
53
|
+
if (current?.state === 'enabled') return { state: 'repaired', plugin: current };
|
|
54
|
+
if (!['missing', 'disabled'].includes(current?.state)) throw fail(current?.reason || 'verification_failed');
|
|
55
|
+
if (provider === 'claude' && current.scope && current.scope !== 'user') throw fail('scope_requires_attention');
|
|
56
|
+
let sources = await inspectSources({ home });
|
|
57
|
+
if (provider === 'codex' && current.state === 'disabled') {
|
|
58
|
+
const payload = JSON.parse((await command(['plugin', 'list', '--json'])).stdout);
|
|
59
|
+
const entry = payload.installed?.find(item => item.pluginId === current.selector);
|
|
60
|
+
if (entry?.source?.source !== 'local' || !entry.source.path ||
|
|
61
|
+
await realpath(entry.source.path) !== await realpath(sources.codex_plugin.path)) throw fail('source_conflict');
|
|
62
|
+
const marketplace = JSON.parse(await readFile(sources.codex_plugin.marketplace_path, 'utf8'));
|
|
63
|
+
if (current.selector !== `simy@${marketplace.name}`) throw fail('source_conflict');
|
|
64
|
+
}
|
|
65
|
+
if (provider === 'claude') {
|
|
66
|
+
const marketplaces = JSON.parse((await command(['plugin', 'marketplace', 'list', '--json'])).stdout);
|
|
67
|
+
if (!Array.isArray(marketplaces)) throw fail('invalid_inventory');
|
|
68
|
+
const existing = marketplaces.find(item => item.name === CLAUDE_MARKETPLACE);
|
|
69
|
+
if (existing && (existing.source !== 'directory' || !existing.path ||
|
|
70
|
+
await realpath(existing.path) !== await realpath(sources.claude_plugin.marketplace_root))) throw fail('source_conflict');
|
|
71
|
+
}
|
|
72
|
+
await check();
|
|
73
|
+
sources = await prepare({ home, platforms: [provider], migrateLegacyGate: false });
|
|
74
|
+
if (provider === 'codex') {
|
|
75
|
+
const marketplace = JSON.parse(await readFile(sources.codex_plugin.marketplace_path, 'utf8'));
|
|
76
|
+
if (!/^[A-Za-z0-9_-]+$/.test(marketplace.name || '')) throw fail('source_conflict');
|
|
77
|
+
const selector = `simy@${marketplace.name}`;
|
|
78
|
+
if (current.selector && current.selector !== selector) throw fail('source_conflict');
|
|
79
|
+
await command(['plugin', 'add', selector, '--json']);
|
|
80
|
+
} else {
|
|
81
|
+
// The managed marketplace installer preserves unrelated provider settings.
|
|
82
|
+
const marketplaces = JSON.parse((await command(['plugin', 'marketplace', 'list', '--json'])).stdout);
|
|
83
|
+
if (!Array.isArray(marketplaces)) throw fail('invalid_inventory');
|
|
84
|
+
const existing = marketplaces.find(item => item.name === CLAUDE_MARKETPLACE);
|
|
85
|
+
if (existing && (existing.source !== 'directory' || !existing.path ||
|
|
86
|
+
await realpath(existing.path) !== await realpath(sources.claude_plugin.marketplace_root))) throw fail('source_conflict');
|
|
87
|
+
if (!existing) {
|
|
88
|
+
await command(['plugin', 'marketplace', 'add', sources.claude_plugin.marketplace_root]);
|
|
89
|
+
}
|
|
90
|
+
await command(['plugin', current.state === 'missing' ? 'install' : 'enable', CLAUDE_PLUGIN_SELECTOR, '--scope', 'user']);
|
|
91
|
+
}
|
|
92
|
+
await check();
|
|
93
|
+
const verified = await inventory();
|
|
94
|
+
if (verified?.state !== 'enabled') throw fail(verified?.reason || 'verification_failed');
|
|
95
|
+
return { state: 'repaired', plugin: verified };
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const reason = error?.reason || (signal?.aborted ? 'cancelled' : error?.code === 'provider_timeout' ? 'timeout' :
|
|
98
|
+
error instanceof SyntaxError ? 'invalid_inventory' :
|
|
99
|
+
['EACCES', 'EPERM', 'EROFS'].includes(error?.code) ? 'permission_denied' : 'command_failed');
|
|
100
|
+
onDiagnostic({ provider, reason, elapsedMs: Date.now() - startedAt });
|
|
101
|
+
throw fail(reason);
|
|
102
|
+
} finally {
|
|
103
|
+
if (locked) await rm(lock, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -14,13 +14,22 @@ export async function inspectProviderPlugins({ home = homedir(),
|
|
|
14
14
|
codexHome = process.env.CODEX_HOME || join(home, '.codex'),
|
|
15
15
|
claudeHome = process.env.CLAUDE_CONFIG_DIR || join(home, '.claude'),
|
|
16
16
|
resolveExecutable = resolveBackendExecutable, run = runProviderCommand,
|
|
17
|
-
timeoutMs = 5_000, signal,
|
|
17
|
+
timeoutMs = 5_000, signal, providers = ['codex', 'claude'], details = false,
|
|
18
|
+
onDiagnostic = () => {},
|
|
18
19
|
} = {}) {
|
|
20
|
+
if (providers.some(provider => !['codex', 'claude'].includes(provider))) throw new Error('Unsupported provider.');
|
|
19
21
|
const inspect = async provider => {
|
|
22
|
+
const startedAt = Date.now();
|
|
23
|
+
const unavailable = reason => {
|
|
24
|
+
const elapsedMs = Date.now() - startedAt;
|
|
25
|
+
onDiagnostic({ provider, reason, elapsedMs });
|
|
26
|
+
return { ...unavailablePluginStatus(), ...(details ? { reason, elapsedMs } : {}) };
|
|
27
|
+
};
|
|
20
28
|
try {
|
|
21
|
-
if (signal?.aborted) return
|
|
29
|
+
if (signal?.aborted) return unavailable('cancelled');
|
|
22
30
|
const executable = await resolveExecutable(provider, { signal, timeoutMs });
|
|
23
|
-
if (
|
|
31
|
+
if (signal?.aborted) return unavailable('cancelled');
|
|
32
|
+
if (!executable) return unavailable('cli_missing');
|
|
24
33
|
const { stdout } = await run(executable, ['plugin', 'list', '--json'], {
|
|
25
34
|
timeout: timeoutMs, maxBuffer: 1024 * 1024, signal, cwd: home,
|
|
26
35
|
env: { ...process.env, HOME: home,
|
|
@@ -28,7 +37,7 @@ export async function inspectProviderPlugins({ home = homedir(),
|
|
|
28
37
|
});
|
|
29
38
|
const payload = JSON.parse(stdout);
|
|
30
39
|
const entries = provider === 'codex' ? payload?.installed : payload;
|
|
31
|
-
if (!Array.isArray(entries)) return
|
|
40
|
+
if (!Array.isArray(entries)) return unavailable('invalid_inventory');
|
|
32
41
|
const matches = entries.filter(entry => provider === 'codex'
|
|
33
42
|
? typeof entry?.pluginId === 'string' && /^simy@[A-Za-z0-9_-]+$/.test(entry.pluginId)
|
|
34
43
|
: entry?.id === CLAUDE_PLUGIN_SELECTOR);
|
|
@@ -42,11 +51,14 @@ export async function inspectProviderPlugins({ home = homedir(),
|
|
|
42
51
|
const entry = matches[0];
|
|
43
52
|
if (!entry || provider === 'codex' && entry.installed === false) return { state: 'missing', installed: false, enabled: false };
|
|
44
53
|
if (typeof entry.enabled !== 'boolean' || provider === 'codex' && entry.installed !== true ||
|
|
45
|
-
provider === 'claude' && !scopes.includes(entry.scope)) return
|
|
54
|
+
provider === 'claude' && !scopes.includes(entry.scope)) return unavailable('invalid_inventory');
|
|
46
55
|
return { state: entry.enabled ? 'enabled' : 'disabled', installed: true, enabled: entry.enabled,
|
|
56
|
+
...(details ? { selector: provider === 'codex' ? entry.pluginId : entry.id } : {}),
|
|
47
57
|
...(provider === 'claude' ? { scope: entry.scope, enablementContext: 'home' } : {}) };
|
|
48
|
-
} catch
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return unavailable(signal?.aborted ? 'cancelled' : error?.code === 'provider_timeout' ? 'timeout' :
|
|
60
|
+
error instanceof SyntaxError ? 'invalid_inventory' : 'command_failed');
|
|
61
|
+
}
|
|
49
62
|
};
|
|
50
|
-
|
|
51
|
-
return { codex, claude };
|
|
63
|
+
return Object.fromEntries(await Promise.all(providers.map(async provider => [provider, await inspect(provider)])));
|
|
52
64
|
}
|
package/src/sqm/review-worker.js
CHANGED
|
@@ -115,11 +115,12 @@ export function createSqmLocalReviewWorker({
|
|
|
115
115
|
getHistoryScope = () => null,
|
|
116
116
|
getWorkspaceUrl = () => null,
|
|
117
117
|
recordHistory = async () => {},
|
|
118
|
+
readHistory = async () => [],
|
|
118
119
|
onHistoryError = () => {},
|
|
119
120
|
now = () => Date.now(),
|
|
120
121
|
providerFailureCooldownMs = 5 * 60_000,
|
|
121
122
|
getRecoveryKey = () => null,
|
|
122
|
-
failureCircuit = createSqmFailureCircuit(),
|
|
123
|
+
failureCircuit = createSqmFailureCircuit({ now }),
|
|
123
124
|
} = {}) {
|
|
124
125
|
if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
|
|
125
126
|
let activeJobId = null;
|
|
@@ -158,6 +159,7 @@ export function createSqmLocalReviewWorker({
|
|
|
158
159
|
item.state = state; item.finishedAt = new Date().toISOString();
|
|
159
160
|
item.result = state === "completed" ? "SQM review submitted." : "";
|
|
160
161
|
item.error = state === "failed" ? formatFailure(failure, failureReported) : "";
|
|
162
|
+
if (state === 'completed') for (const field of ['errorCode', 'errorStage', 'httpStatus', 'providerDiagnostic']) delete item[field];
|
|
161
163
|
if (failure) { item.phase = failure.stage; item.errorStage = failure.stage; item.errorCode = failure.code; item.httpStatus = failure.status; item.providerDiagnostic = failure.providerDiagnostic; }
|
|
162
164
|
await persist(scope, item);
|
|
163
165
|
}
|
|
@@ -273,20 +275,22 @@ export function createSqmLocalReviewWorker({
|
|
|
273
275
|
snapshot() {
|
|
274
276
|
const pause = currentPause();
|
|
275
277
|
const circuit = failureCircuit.snapshot(getHistoryScope());
|
|
278
|
+
const requiresInteraction = pause && !pause.recoverable && pause.recoveryKey === getRecoveryKey(getProvider());
|
|
279
|
+
const waitingForRecovery = circuit.recovery_pending && !requiresInteraction && !activeJobId;
|
|
276
280
|
return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, suspended,
|
|
277
|
-
paused: pausedEnvironment !== null || circuit.
|
|
278
|
-
pause_reason:
|
|
281
|
+
paused: !activeJobId && (pausedEnvironment !== null || circuit.recovery_pending || circuit.storage_unavailable),
|
|
282
|
+
pause_reason: activeJobId ? null : requiresInteraction ? pause.code : waitingForRecovery ? "automatic_recovery_pending" : circuit.storage_unavailable ? "failure_state_unavailable" : pauseReason,
|
|
279
283
|
...circuit, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
|
|
280
|
-
...(pause ? { provider_pause: { code: pause.code, status: pause.status,
|
|
281
|
-
retry_at:
|
|
282
|
-
message:
|
|
284
|
+
...(pause && !activeJobId ? { provider_pause: { code: pause.code, status: pause.status,
|
|
285
|
+
retry_at: new Date(Math.max(pause.retryAt, circuit.next_retry_at || 0)).toISOString(),
|
|
286
|
+
message: waitingForRecovery ? "SIMY will automatically retry after the recovery cooldown. No update or manual confirmation is required." : pause.message } } : {}) };
|
|
283
287
|
},
|
|
284
288
|
desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item, canStop: item.id === activeJobId })) }; },
|
|
285
289
|
desktopTaskDetail(key) {
|
|
286
290
|
const item = visibleHistory().find(item => item.key === key);
|
|
287
291
|
return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] } : null;
|
|
288
292
|
},
|
|
289
|
-
retry() { if (closed || suspended || pollPromise || failureCircuit.snapshot(getHistoryScope()).
|
|
293
|
+
retry() { if (closed || suspended || pollPromise || failureCircuit.snapshot(getHistoryScope()).recovery_pending) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
|
|
290
294
|
resetHistory() { if (!pollPromise) { history.splice(0); issue = null; pausedEnvironment = null; pauseReason = null; } },
|
|
291
295
|
poll() {
|
|
292
296
|
if (closed || suspended || pollPromise) return pollPromise || Promise.resolve(null);
|
|
@@ -295,7 +299,8 @@ export function createSqmLocalReviewWorker({
|
|
|
295
299
|
const pause = currentPause();
|
|
296
300
|
// Known provider failures already have a bounded cooldown. Do not let
|
|
297
301
|
// the environment gate suppress their recovery probe indefinitely.
|
|
298
|
-
if (
|
|
302
|
+
if (pause?.recoveryKey === getRecoveryKey(getProvider()) &&
|
|
303
|
+
(!pause.recoverable || now() < pause.retryAt)) return Promise.resolve(null);
|
|
299
304
|
pausedEnvironment = null; pauseReason = null;
|
|
300
305
|
} else if (pausedEnvironment !== null) {
|
|
301
306
|
pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null;
|
|
@@ -323,6 +328,23 @@ export function createSqmLocalReviewWorker({
|
|
|
323
328
|
lease_seconds: leaseSeconds,
|
|
324
329
|
}, { signal: reviewController.signal });
|
|
325
330
|
if (issue) { issue = null; onChange(); }
|
|
331
|
+
if (claim?.recovered === true && claim.completed === true && typeof claim.job_id === 'string') {
|
|
332
|
+
// The server completed this exact saved result without re-running a
|
|
333
|
+
// provider. Reflect the original job's completion locally as well.
|
|
334
|
+
if (!history.some(item => item.id === claim.job_id)) {
|
|
335
|
+
let saved;
|
|
336
|
+
try { saved = (await readHistory(historyScope)).find(item => item.key === `sqm:${claim.job_id}`); }
|
|
337
|
+
catch { onHistoryError(); }
|
|
338
|
+
const item = { ...saved, id: claim.job_id, key: `sqm:${claim.job_id}`, kind: 'analysis',
|
|
339
|
+
label: saved?.label || `SQM · ${claim.job_id.slice(0, 8)}`, provider: saved?.provider || provider,
|
|
340
|
+
startedAt: saved?.startedAt || new Date().toISOString(), webUrl: saved?.webUrl || analysisTaskUrl(workspaceUrl, claim.job_id) };
|
|
341
|
+
history.unshift(item); historyScopes.set(item, JSON.stringify(historyScope)); history.splice(50);
|
|
342
|
+
}
|
|
343
|
+
await updateTask(claim.job_id, 'completed', historyScope);
|
|
344
|
+
await failureCircuit.success(historyScope);
|
|
345
|
+
pauses.delete(key);
|
|
346
|
+
return claim;
|
|
347
|
+
}
|
|
326
348
|
if (!claim?.claimed) {
|
|
327
349
|
// A successful empty claim proves SIMY access recovered, but it says
|
|
328
350
|
// nothing about a local provider that has not executed again yet.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {readFile,writeFile,mkdir,realpath} from 'node:fs/promises';
|
|
2
|
+
import {join,resolve} from 'node:path';
|
|
3
|
+
import {createHash,randomUUID} from 'node:crypto';
|
|
4
|
+
import {digest} from './state-store.js';
|
|
5
|
+
import {readDispatchGate,reconcileDispatchHistory} from './dispatch-gate.js';
|
|
6
|
+
import {createUpdateRuntime} from './runtime.js';
|
|
7
|
+
import {createUpdateCoordinator} from './coordinator.js';
|
|
8
|
+
import {readSession,isSessionValid} from '../session-store.js';
|
|
9
|
+
import {webApiUrl,webApiHeaders} from '../web-api.js';
|
|
10
|
+
import {acquireDesktopProcessLock} from '../desktop-process-lock.js';
|
|
11
|
+
import {listRecords} from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
12
|
+
const sha=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
13
|
+
export async function planDispatchHistoryRecovery({root}) {
|
|
14
|
+
root=await realpath(root);const state=readDispatchGate(root),items=[];
|
|
15
|
+
for(const attempt of Object.values(state.attempts)) {
|
|
16
|
+
const item={attemptId:attempt.id,attemptDigest:digest(attempt),disposition:attempt.phase==='confirmed'?'already_confirmed':'retain_blocked_unknown'};
|
|
17
|
+
const ref=attempt.provenance?.recordRef;
|
|
18
|
+
if(attempt.phase==='result_saved' && ref?.startsWith(resolve(root,'direct-tasks')+'/')) {
|
|
19
|
+
try {
|
|
20
|
+
const bytes=await readFile(ref),result=JSON.parse(bytes),match=/^(.*):provider:([1-9][0-9]*)$/.exec(attempt.id);
|
|
21
|
+
if(await realpath(ref)===ref && match && result.id===match[1] && result.id===attempt.provenance.taskId && digest(result.id)===attempt.taskHash &&
|
|
22
|
+
result.invocation_count===Number(match[2]) && ['succeeded','failed','stopped'].includes(result.status) && Object.values(state.receipts).some(r=>r.attemptId===attempt.id && r.generation===attempt.generation && r.resultRef===ref)) {
|
|
23
|
+
item.disposition='saved_terminal_result';item.evidence={ref,sha256:sha(bytes),status:result.status};
|
|
24
|
+
}
|
|
25
|
+
} catch { /* Unknown effects remain blocked, including removed test output. */ }
|
|
26
|
+
}
|
|
27
|
+
items.push(item);
|
|
28
|
+
}
|
|
29
|
+
const audit={version:1,gateDigest:digest(state),expectedRevision:state.revision,expectedGeneration:state.generation,items};
|
|
30
|
+
const directory=join(root,'update-detachment','recovery');await mkdir(directory,{recursive:true,mode:0o700});
|
|
31
|
+
const auditRef=join(directory,randomUUID()+'.json'),bytes=JSON.stringify(audit,null,2)+'\n';await writeFile(auditRef,bytes,{flag:'wx',mode:0o600});
|
|
32
|
+
return {auditRef,auditDigest:sha(bytes),revision:state.revision,generation:state.generation,results:items.filter(i=>i.disposition==='saved_terminal_result').length,blocked:items.filter(i=>i.disposition==='retain_blocked_unknown').length,replayAttempts:false};
|
|
33
|
+
}
|
|
34
|
+
export async function applyDispatchHistoryRecovery({root,auditRef,auditDigest}) {
|
|
35
|
+
root=await realpath(root);const lock=await acquireDesktopProcessLock(root);
|
|
36
|
+
try {const audit=JSON.parse(await readFile(auditRef,'utf8'));return reconcileDispatchHistory({root,auditRef,auditDigest,expectedRevision:audit.expectedRevision,expectedGeneration:audit.expectedGeneration});}
|
|
37
|
+
finally {await lock.release();}
|
|
38
|
+
}
|
|
39
|
+
export async function reconnectRecoveredHistory({root,origin='https://app.simy.one'}) {
|
|
40
|
+
root=await realpath(root);const lock=await acquireDesktopProcessLock(root);
|
|
41
|
+
try {
|
|
42
|
+
const session=await readSession(origin,root);if(!isSessionValid(session,Date.now(),origin))throw Error('Existing device sign-in required.');
|
|
43
|
+
const response=await fetch(webApiUrl('desktop-access',{webOrigin:origin,apiBaseUrl:session.api_base_url}),{headers:webApiHeaders(session),signal:AbortSignal.timeout(10000)});
|
|
44
|
+
const access=await response.json();if(!response.ok || access.state!=='ready' || access.organizationId!==session.organization_id)throw Error('Current server entitlement required.');
|
|
45
|
+
const runtime=createUpdateRuntime({root,origin,readContext:()=>({actor:{account:session.account_id,device:session.device_id,origin},runs:[],directTasks:[],localTasks:[]}),listRecords:()=>listRecords(join(root,'delivery-hooks'))});
|
|
46
|
+
// Reconciliation reads exact results or retained fences. It never resumes a task.
|
|
47
|
+
const coordinator=createUpdateCoordinator({store:runtime.store,gate:runtime.gate,boundary:runtime.boundary,reconcile:runtime.reconcile,initialState:'recovery_required'});
|
|
48
|
+
if(digest(await readSession(origin,root))!==digest(session))throw Error('Device connection changed.');
|
|
49
|
+
return await coordinator.reconnect();
|
|
50
|
+
} finally {await lock.release();}
|
|
51
|
+
}
|
|
@@ -6,7 +6,7 @@ import { execFile } from 'node:child_process';
|
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { readFile, lstat, readlink, realpath } from 'node:fs/promises';
|
|
8
8
|
import { atomicSave, readState, digest, withLock } from './state-store.js';
|
|
9
|
-
import { readDispatchGate, adoptHookOwner, DISPATCH_PROTOCOL, confirmDispatch, flushDispatchIntake, readTaskAdmission } from './dispatch-gate.js';
|
|
9
|
+
import { readDispatchGate, unresolvedDispatchAttempts, adoptHookOwner, DISPATCH_PROTOCOL, confirmDispatch, flushDispatchIntake, readTaskAdmission } from './dispatch-gate.js';
|
|
10
10
|
import { createLedgerCoordinatorAdapters } from './ledger-adapter.js';
|
|
11
11
|
import { listDispatchRegistrations, normalizeDispatchIdentity } from './provenance.js';
|
|
12
12
|
import { reconcileSession } from './reconcile.js';
|
|
@@ -182,7 +182,7 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
|
|
|
182
182
|
confirmDispatch({root,attemptId:attempt.id});
|
|
183
183
|
}
|
|
184
184
|
ledger=readDispatchGate(root);
|
|
185
|
-
const unresolved=
|
|
185
|
+
const unresolved=unresolvedDispatchAttempts(ledger).filter(attempt=> !workers.some(task=>task.handle.attemptId===attempt.id && attempt.taskHash===digest(task.taskId) && attempt.identityHash===digest(task.identity) && attempt.generation===task.generation));
|
|
186
186
|
if(unresolved.length)return {safe:false,liveExecutions:0,pendingWrites:0,unsettledPermissions:0};
|
|
187
187
|
const currentGate=readDispatchGate(root);
|
|
188
188
|
if(currentGate.connection!=='detaching' || currentGate.generation!==startingGate.generation)throw failure('boundary_owner_changed');
|
|
@@ -194,7 +194,7 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
|
|
|
194
194
|
const reconcile=async()=> {
|
|
195
195
|
const actor=context().actor, assessments=[];
|
|
196
196
|
const workers=(transaction?.tasks || []).filter(task=>task.handle);
|
|
197
|
-
if(
|
|
197
|
+
if(unresolvedDispatchAttempts(readDispatchGate(root)).some(attempt=> !workers.some(task=>task.handle.attemptId===attempt.id)))return {verified:false,unresolvedAttempts:1};
|
|
198
198
|
for (const task of transaction?.tasks || []) {
|
|
199
199
|
if(task.handle) {
|
|
200
200
|
if(actor?.account!==task.identity.account || actor?.origin!==task.identity.origin || actor?.device!==task.identity.device || task.identity.root!==root)return {verified:false,unresolvedAttempts:1};
|