@awak-app/simy-cli 0.7.22 → 0.7.25
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 +31 -17
- package/package.json +2 -2
- package/src/agent.js +44 -9
- package/src/auto-update.js +3 -2
- package/src/browser-handoff.js +14 -3
- 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/connection-overview.js +58 -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 +11 -3
- package/src/desktop-status.js +9 -0
- 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/bundle-store.js +30 -2
- package/src/sqm/failure-circuit.js +39 -7
- package/src/sqm/provider-plugin-status.js +21 -3
- 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
- package/src/web-origin.js +7 -0
|
@@ -6,6 +6,10 @@ import { CLI_VERSION } from "../cli-contract.js";
|
|
|
6
6
|
import { defaultSessionRoot } from "../session-store.js";
|
|
7
7
|
|
|
8
8
|
export const SQM_FAILURE_PAUSE_THRESHOLD = 6;
|
|
9
|
+
export const SQM_RECOVERY_DELAY_MS = 5 * 60_000;
|
|
10
|
+
const MAX_RECOVERY_DELAY_MS = 30 * 60_000;
|
|
11
|
+
const exhausted = state => state.failures >= SQM_FAILURE_PAUSE_THRESHOLD || (state.providerFailures || 0) >= 2;
|
|
12
|
+
const recoveryDelay = state => Math.min(MAX_RECOVERY_DELAY_MS, SQM_RECOVERY_DELAY_MS * 2 ** Math.min(state.recoveryAttempts || 0, 3));
|
|
9
13
|
const scopeKey = scope => JSON.stringify([scope?.webOrigin, scope?.accountId, scope?.organizationId]);
|
|
10
14
|
const validVersion = value => typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value);
|
|
11
15
|
|
|
@@ -21,6 +25,9 @@ export function createSqmFailureCircuitStore({ root = defaultSessionRoot() } = {
|
|
|
21
25
|
const state = JSON.parse(await readFile(path(scope), "utf8"));
|
|
22
26
|
if (!validVersion(state.version) || !Number.isInteger(state.failures) || state.failures < 0 || state.failures > SQM_FAILURE_PAUSE_THRESHOLD || (state.inFlight !== undefined && typeof state.inFlight !== "boolean") || (state.providerFailures !== undefined && (!Number.isInteger(state.providerFailures) || state.providerFailures < 0 || state.providerFailures > 2)) || (state.providerRetryAt !== undefined && (!Number.isFinite(state.providerRetryAt) || state.providerRetryAt < 0)))
|
|
23
27
|
throw new Error("Invalid saved SQM failure state.");
|
|
28
|
+
if (state.recoveryAt !== undefined && (!Number.isFinite(state.recoveryAt) || state.recoveryAt < 0) ||
|
|
29
|
+
state.recoveryAttempts !== undefined && (!Number.isSafeInteger(state.recoveryAttempts) || state.recoveryAttempts < 0))
|
|
30
|
+
throw new Error("Invalid saved SQM recovery state.");
|
|
24
31
|
return state;
|
|
25
32
|
} catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
26
33
|
},
|
|
@@ -37,12 +44,17 @@ export function createSqmFailureCircuitStore({ root = defaultSessionRoot() } = {
|
|
|
37
44
|
|
|
38
45
|
// One counter per organization on this machine, shared by both local engines.
|
|
39
46
|
// Neither reconnecting nor an empty claim demonstrates successful analysis.
|
|
40
|
-
export function createSqmFailureCircuit({ version = CLI_VERSION, store = null } = {}) {
|
|
47
|
+
export function createSqmFailureCircuit({ version = CLI_VERSION, store = null, now = Date.now } = {}) {
|
|
41
48
|
if (!validVersion(version)) throw new Error("SQM failure circuit requires a SIMY version.");
|
|
42
49
|
const states = new Map();
|
|
43
50
|
const unreadableScopes = new Set();
|
|
51
|
+
const scheduleRecovery = state => {
|
|
52
|
+
if (!exhausted(state) || state.recoveryAt > now()) return false;
|
|
53
|
+
state.recoveryAt = Math.max(state.providerRetryAt || 0, now() + recoveryDelay(state));
|
|
54
|
+
return true;
|
|
55
|
+
};
|
|
44
56
|
const save = async (scope, state) => {
|
|
45
|
-
try { await store?.write(scope, { version: state.version, failures: state.failures, inFlight: state.inFlight === true, ...(state.providerFailures ? { providerFailures: state.providerFailures, providerRetryAt: state.providerRetryAt } : {}) }); state.storageError = false; }
|
|
57
|
+
try { await store?.write(scope, { version: state.version, failures: state.failures, inFlight: state.inFlight === true, ...(state.recoveryAt !== undefined ? { recoveryAt: state.recoveryAt, recoveryAttempts: state.recoveryAttempts || 0 } : {}), ...(state.providerFailures ? { providerFailures: state.providerFailures, providerRetryAt: state.providerRetryAt } : {}) }); state.storageError = false; }
|
|
46
58
|
catch (error) { state.storageError = true; throw error; }
|
|
47
59
|
};
|
|
48
60
|
const load = async scope => {
|
|
@@ -52,7 +64,7 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
|
|
|
52
64
|
let saved;
|
|
53
65
|
try { saved = await store?.read(scope); unreadableScopes.delete(key); }
|
|
54
66
|
catch (error) { unreadableScopes.add(key); throw error; }
|
|
55
|
-
state = saved ? { version: saved.version, failures: saved.failures, inFlight: saved.inFlight === true, providerFailures: saved.providerFailures || 0, providerRetryAt: saved.providerRetryAt || 0 } : { version, failures: 0, inFlight: false };
|
|
67
|
+
state = saved ? { version: saved.version, failures: saved.failures, inFlight: saved.inFlight === true, providerFailures: saved.providerFailures || 0, providerRetryAt: saved.providerRetryAt || 0, ...(saved.recoveryAt !== undefined ? { recoveryAt: saved.recoveryAt, recoveryAttempts: saved.recoveryAttempts || 0 } : {}) } : { version, failures: 0, inFlight: false };
|
|
56
68
|
states.set(key, state);
|
|
57
69
|
// An unfinished review from this same version means the last host exited
|
|
58
70
|
// without a result or graceful cancellation. Count it once on restart.
|
|
@@ -60,6 +72,7 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
|
|
|
60
72
|
state.failures = Math.min(SQM_FAILURE_PAUSE_THRESHOLD, state.failures + 1);
|
|
61
73
|
if (state.providerFailures === 1) state.providerFailures = 2;
|
|
62
74
|
state.inFlight = false;
|
|
75
|
+
scheduleRecovery(state);
|
|
63
76
|
await save(scope, state);
|
|
64
77
|
}
|
|
65
78
|
}
|
|
@@ -67,20 +80,35 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
|
|
|
67
80
|
state.version = version;
|
|
68
81
|
state.providerFailures = 0; state.providerRetryAt = 0;
|
|
69
82
|
state.failures = 0;
|
|
83
|
+
delete state.recoveryAt; delete state.recoveryAttempts;
|
|
70
84
|
state.inFlight = false;
|
|
71
85
|
await save(scope, state);
|
|
72
86
|
} else if (state.storageError) await save(scope, state);
|
|
87
|
+
// Migrate a legacy update-only pause once, retaining counters and scope.
|
|
88
|
+
if (exhausted(state) && state.recoveryAt === undefined) {
|
|
89
|
+
scheduleRecovery(state); await save(scope, state);
|
|
90
|
+
}
|
|
73
91
|
return state;
|
|
74
92
|
};
|
|
75
93
|
return {
|
|
76
|
-
async canClaim(scope,
|
|
94
|
+
async canClaim(scope, time = now()) {
|
|
77
95
|
const state = await load(scope);
|
|
78
|
-
|
|
96
|
+
if (time < (state.providerRetryAt || 0) || state.inFlight) return false;
|
|
97
|
+
if (!exhausted(state)) return true;
|
|
98
|
+
if (time < state.recoveryAt) return false;
|
|
99
|
+
// Reserve the next cooldown before claiming. Empty claims, reconnects and
|
|
100
|
+
// cancelled work never reset failures or allow a burst of recovery turns.
|
|
101
|
+
state.recoveryAttempts = Math.min((state.recoveryAttempts || 0) + 1, 3);
|
|
102
|
+
state.recoveryAt = time + recoveryDelay(state);
|
|
103
|
+
await save(scope, state);
|
|
104
|
+
return true;
|
|
79
105
|
},
|
|
80
106
|
async providerFailure(scope, retryAt, retryAttempt = 0) {
|
|
81
107
|
const state = await load(scope);
|
|
82
108
|
state.providerFailures = retryAttempt === 1 ? 2 : Math.min(2, (state.providerFailures || 0) + 1);
|
|
83
109
|
state.providerRetryAt = retryAt;
|
|
110
|
+
scheduleRecovery(state);
|
|
111
|
+
if (state.recoveryAt !== undefined) state.recoveryAt = Math.max(state.recoveryAt, retryAt);
|
|
84
112
|
await save(scope, state);
|
|
85
113
|
},
|
|
86
114
|
async providerRetryUsed(scope) {
|
|
@@ -104,12 +132,14 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
|
|
|
104
132
|
state.failures = Math.min(SQM_FAILURE_PAUSE_THRESHOLD, state.failures + 1);
|
|
105
133
|
if (state.inFlight && state.providerFailures === 1) state.providerFailures = 2;
|
|
106
134
|
state.inFlight = false;
|
|
135
|
+
scheduleRecovery(state);
|
|
107
136
|
await save(scope, state);
|
|
108
137
|
},
|
|
109
138
|
async success(scope) {
|
|
110
139
|
const state = await load(scope);
|
|
111
140
|
state.failures = 0;
|
|
112
141
|
state.providerFailures = 0; state.providerRetryAt = 0;
|
|
142
|
+
delete state.recoveryAt; delete state.recoveryAttempts;
|
|
113
143
|
state.inFlight = false;
|
|
114
144
|
await save(scope, state);
|
|
115
145
|
},
|
|
@@ -117,10 +147,12 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
|
|
|
117
147
|
const state = states.get(scopeKey(scope));
|
|
118
148
|
const paused = (state?.failures || 0) >= SQM_FAILURE_PAUSE_THRESHOLD || (state?.providerFailures || 0) >= 2;
|
|
119
149
|
return { consecutive_failures: state?.failures || 0,
|
|
120
|
-
paused_until_update:
|
|
150
|
+
paused_until_update: false,
|
|
151
|
+
recovery_pending: paused && state?.inFlight !== true,
|
|
152
|
+
next_retry_at: paused ? state.recoveryAt ?? null : null,
|
|
121
153
|
provider_retry_exhausted: (state?.providerFailures || 0) >= 2,
|
|
122
154
|
provider_recovery_pending: state?.providerFailures === 1,
|
|
123
|
-
paused_at_version:
|
|
155
|
+
paused_at_version: null,
|
|
124
156
|
storage_unavailable: state?.storageError === true || unreadableScopes.has(scopeKey(scope)) };
|
|
125
157
|
},
|
|
126
158
|
};
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
3
4
|
import { resolveBackendExecutable } from '../backend-executable.js';
|
|
4
5
|
import { runProviderCommand } from './provider-command.js';
|
|
5
6
|
import { CLAUDE_PLUGIN_SELECTOR } from './claude-plugin.js';
|
|
6
7
|
|
|
8
|
+
// The managed marketplace selects the inventory scope, never installation or
|
|
9
|
+
// enablement evidence. Those still come from the provider's actual response.
|
|
10
|
+
async function managedCodexMarketplace({ home, signal }) {
|
|
11
|
+
try {
|
|
12
|
+
const bytes = await readFile(join(home, '.agents', 'plugins', 'marketplace.json'), { encoding: 'utf8', signal });
|
|
13
|
+
if (Buffer.byteLength(bytes) > 64 * 1024) return null;
|
|
14
|
+
const manifest = JSON.parse(bytes);
|
|
15
|
+
return /^[A-Za-z0-9_-]+$/.test(manifest?.name || '') && Array.isArray(manifest.plugins) &&
|
|
16
|
+
manifest.plugins.some(entry => entry?.name === 'simy') ? manifest.name : null;
|
|
17
|
+
} catch { return null; }
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
export const unavailablePluginStatus = () => ({ state: 'unknown', installed: null, enabled: null });
|
|
8
21
|
|
|
9
22
|
// Provider inventories describe the actual installation and enablement. Managed
|
|
@@ -15,7 +28,7 @@ export async function inspectProviderPlugins({ home = homedir(),
|
|
|
15
28
|
claudeHome = process.env.CLAUDE_CONFIG_DIR || join(home, '.claude'),
|
|
16
29
|
resolveExecutable = resolveBackendExecutable, run = runProviderCommand,
|
|
17
30
|
timeoutMs = 5_000, signal, providers = ['codex', 'claude'], details = false,
|
|
18
|
-
onDiagnostic = () => {},
|
|
31
|
+
onDiagnostic = () => {}, readCodexMarketplace = managedCodexMarketplace,
|
|
19
32
|
} = {}) {
|
|
20
33
|
if (providers.some(provider => !['codex', 'claude'].includes(provider))) throw new Error('Unsupported provider.');
|
|
21
34
|
const inspect = async provider => {
|
|
@@ -30,7 +43,12 @@ export async function inspectProviderPlugins({ home = homedir(),
|
|
|
30
43
|
const executable = await resolveExecutable(provider, { signal, timeoutMs });
|
|
31
44
|
if (signal?.aborted) return unavailable('cancelled');
|
|
32
45
|
if (!executable) return unavailable('cli_missing');
|
|
33
|
-
const
|
|
46
|
+
const marketplace = provider === 'codex' ? await readCodexMarketplace({ home, signal }) : null;
|
|
47
|
+
if (signal?.aborted) return unavailable('cancelled');
|
|
48
|
+
// A global inventory may contact unrelated remote marketplaces. Query the
|
|
49
|
+
// configured SIMY source so their network delays cannot hide local state.
|
|
50
|
+
const args = ['plugin', 'list', '--json', ...(marketplace ? ['--marketplace', marketplace] : [])];
|
|
51
|
+
const { stdout } = await run(executable, args, {
|
|
34
52
|
timeout: timeoutMs, maxBuffer: 1024 * 1024, signal, cwd: home,
|
|
35
53
|
env: { ...process.env, HOME: home,
|
|
36
54
|
...(provider === 'codex' ? { CODEX_HOME: codexHome } : { CLAUDE_CONFIG_DIR: claudeHome }) },
|
|
@@ -39,7 +57,7 @@ export async function inspectProviderPlugins({ home = homedir(),
|
|
|
39
57
|
const entries = provider === 'codex' ? payload?.installed : payload;
|
|
40
58
|
if (!Array.isArray(entries)) return unavailable('invalid_inventory');
|
|
41
59
|
const matches = entries.filter(entry => provider === 'codex'
|
|
42
|
-
? typeof entry?.pluginId === 'string' && /^simy@[A-Za-z0-9_-]+$/.test(entry.pluginId)
|
|
60
|
+
? typeof entry?.pluginId === 'string' && /^simy@[A-Za-z0-9_-]+$/.test(entry.pluginId) && (!marketplace || entry.pluginId === `simy@${marketplace}`)
|
|
43
61
|
: entry?.id === CLAUDE_PLUGIN_SELECTOR);
|
|
44
62
|
// Prefer the account installation; report a project-only install explicitly
|
|
45
63
|
// rather than silently treating it as an installation for every project.
|
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};
|
package/src/web-origin.js
CHANGED
|
@@ -44,3 +44,10 @@ export function isRequestOriginAllowed(requestOrigin, webOrigin) {
|
|
|
44
44
|
return false;
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
|
|
48
|
+
// Explicitly retain the resolved host when a command is copied to a fresh shell.
|
|
49
|
+
export function daemonRestartCommand(webOrigin) {
|
|
50
|
+
const origin = resolveWebOrigin(webOrigin);
|
|
51
|
+
const quotedOrigin = '"' + origin.replace(/["\\$`]/g, "\\$&") + '"';
|
|
52
|
+
return `simy --daemon --host ${quotedOrigin}`;
|
|
53
|
+
}
|