@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
package/README.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# SIMY CLI
|
|
2
2
|
|
|
3
|
+
**Copy connection URL / 接続用URLをコピー** copies the current SIMY authentication
|
|
4
|
+
link, including its pending organization selection. It is available while waiting
|
|
5
|
+
for the browser and in settings. Paste it into Chrome if automatic opening fails.
|
|
6
|
+
**Copy workspace URL** continues to copy only the Web workspace address.
|
|
7
|
+
This signs the desktop into SIMY; browser automation uses its own provider connection.
|
|
8
|
+
|
|
9
|
+
Technical coding failures retain their task and retry after a persisted 5–30 minute
|
|
10
|
+
cooldown. Automatic recovery preserves the original goal, usage limits, provider
|
|
11
|
+
connection and explicit stops. Enabling automatic continuation can also adopt an
|
|
12
|
+
ordinary loop waiting for an external dependency, starting with read-only inspection.
|
|
13
|
+
A damaged provider-link file can be repaired by **Connect SIMY** for that provider;
|
|
14
|
+
the original file is retained and the other provider stays disconnected.
|
|
15
|
+
|
|
3
16
|
### Experimental delegated approval and input bridge
|
|
4
17
|
|
|
5
18
|
`simy delivery interactions proxy --policy-file /absolute/private/policy.json`
|
|
@@ -93,17 +106,15 @@ Both providers share one analysis deadline. A deterministic provider failure pau
|
|
|
93
106
|
new claims and retains failed history until environment/settings change or **Retry
|
|
94
107
|
analysis** is selected.
|
|
95
108
|
|
|
96
|
-
After **6 consecutive SQM analysis failures**, this machine
|
|
97
|
-
SQM
|
|
98
|
-
counter; a completed analysis resets it.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
The native organization menu shows when an update is required. This pause does
|
|
106
|
-
not change server analysis consent or stop ordinary implementation tasks.
|
|
109
|
+
After **6 consecutive SQM analysis failures**, this machine waits before its next
|
|
110
|
+
SQM claim for the current Web/account/organization. Codex and Claude share the
|
|
111
|
+
counter; a completed analysis resets it. The cooldown increases from 5 to 30
|
|
112
|
+
minutes and survives restart. Empty polls and reconnects do not reset failures.
|
|
113
|
+
Retries resume automatically while analysis and the provider connection remain
|
|
114
|
+
enabled. Authentication requiring the account holder remains a sign-in wait.
|
|
115
|
+
The server's durable recovery migration requeues the same failed job and retains
|
|
116
|
+
its input, history and attempt count; it is required to recover jobs previously
|
|
117
|
+
held by the server's lifetime retry cap.
|
|
107
118
|
|
|
108
119
|
Agentic Loop preflight accepts the task's existing requirement, acceptance
|
|
109
120
|
criteria, required checks and charter context alongside the repository/provider.
|
|
@@ -1020,12 +1031,15 @@ records cannot be recovered.
|
|
|
1020
1031
|
History remains visible with local analysis off and after restarting. A saved
|
|
1021
1032
|
in-flight analysis is shown as Interrupted until this client claims it again;
|
|
1022
1033
|
it does not contribute to the running count. The web workspace owns retry status.
|
|
1023
|
-
Model/provider execution failures permit
|
|
1024
|
-
cooldown, even when the app/account identity is unchanged.
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1034
|
+
Model/provider execution failures permit a recovery claim after a five-minute
|
|
1035
|
+
cooldown, even when the app/account identity is unchanged. Repeated provider
|
|
1036
|
+
failures or six consecutive analysis failures schedule further automatic recovery
|
|
1037
|
+
with a persisted 5–30 minute backoff. Reconnects, restarts, empty claims and
|
|
1038
|
+
cancelled work do not reset the failure counters or cooldown. Only a successful
|
|
1039
|
+
review or a newer SIMY version clears the circuit. Authentication interaction,
|
|
1040
|
+
local installation, SIMY access and persistence requirements remain enforced;
|
|
1041
|
+
waiting does not grant permission. Turning analysis off or disconnecting its
|
|
1042
|
+
provider still prevents new work.
|
|
1029
1043
|
|
|
1030
1044
|
Only summary metadata is retained, without analysis prompts, source inputs,
|
|
1031
1045
|
model responses or credentials. Historical summaries are not a replacement for
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.25",
|
|
4
4
|
"description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
14
|
"start": "node ./src/index.js",
|
|
15
|
-
"test": "node --test --test-concurrency=2",
|
|
15
|
+
"test": "node --import ./test/isolate-simy-home.mjs --test --test-concurrency=2",
|
|
16
16
|
"test:e2e:console": "node ./scripts/console-e2e.js",
|
|
17
17
|
"test:e2e:work-activity": "node ./scripts/work-activity-e2e.js",
|
|
18
18
|
"test:e2e:sqm-complete-loop": "node ./scripts/sqm-complete-loop-e2e.js",
|
package/src/agent.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { connectionOverview, readSqmConnectionObservation } from './connection-overview.js';
|
|
1
2
|
import { readLocalPhotoSource } from "./local-photos.js";
|
|
2
3
|
import { resolveLocalTaskDestination, saveLocalTaskDestination } from "./local-task-destination.js";
|
|
3
4
|
import { observePairedNativeIdentities } from './native-provider-identity.js';
|
|
@@ -18,6 +19,7 @@ import { createDesktopAccessPolicy, desktopAccessReady, requireDesktopAccess } f
|
|
|
18
19
|
import { readSqmEnabled, writeSqmEnabled, readSqmSettings, writeSqmEngineFallback } from "./desktop-sqm-settings.js";
|
|
19
20
|
import { createServer } from "node:http";
|
|
20
21
|
import { createHash, randomBytes } from "node:crypto";
|
|
22
|
+
import { canRecoverCodingRun, codingRecoveryDue, codingRecoveryLinksMatch } from './coding-run-automatic-recovery.js';
|
|
21
23
|
import { createReadStream } from "node:fs";
|
|
22
24
|
import { dirname, join, resolve } from "node:path";
|
|
23
25
|
|
|
@@ -35,6 +37,7 @@ import {
|
|
|
35
37
|
prepareLocalCodingRunRecovery,
|
|
36
38
|
reconcileLocalCodingRun,
|
|
37
39
|
recoverCompletedLocalCodingRun,
|
|
40
|
+
recoverLocalCodingRun,
|
|
38
41
|
queueLocalCodingGuidance,
|
|
39
42
|
recheckLocalCodingRun,
|
|
40
43
|
publishRestoredLocalCodingRun,
|
|
@@ -545,7 +548,7 @@ export async function startAgent({
|
|
|
545
548
|
return inspection;
|
|
546
549
|
};
|
|
547
550
|
|
|
548
|
-
const requireRunProvider = async (run, { backend = run.request.backend } = {}) => {
|
|
551
|
+
const requireRunProvider = async (run, { backend = run.request.backend, automatic = false } = {}) => {
|
|
549
552
|
const scope = await authorizeDesktopWork();
|
|
550
553
|
const inspection = await inspectReadyBackend(backend, { force: true });
|
|
551
554
|
await authorizeDesktopWork(scope);
|
|
@@ -555,18 +558,19 @@ export async function startAgent({
|
|
|
555
558
|
code: `desktop_executor_${inspection.status}`,
|
|
556
559
|
});
|
|
557
560
|
}
|
|
558
|
-
await bindRunAuthorization(run, scope, inspection, backend);
|
|
561
|
+
await bindRunAuthorization(run, scope, inspection, backend, !automatic);
|
|
559
562
|
};
|
|
560
563
|
|
|
561
564
|
const authorizeProviderDispatch = async (backend, scope, link) => {
|
|
562
565
|
await authorizeDesktopWork(scope);
|
|
563
566
|
await requireLinkedProvider(backend, link);
|
|
564
567
|
};
|
|
565
|
-
const bindRunAuthorization = async (run, scope, inspection, backend = run.request.backend) => {
|
|
568
|
+
const bindRunAuthorization = async (run, scope, inspection, backend = run.request.backend, rememberConnection = true) => {
|
|
566
569
|
const links = await readProviderLinks(sessionRoot);
|
|
567
570
|
links[backend] = inspectedLinks.get(inspection);
|
|
568
571
|
await authorizeProviderDispatch(backend, scope, links[backend]);
|
|
569
572
|
runAuthorizations.set(run, { scope, links });
|
|
573
|
+
if (rememberConnection) run.snapshot.metadata.provider_link_epochs = Object.fromEntries(Object.entries(links).map(([provider, link]) => [provider, link.epoch]));
|
|
570
574
|
};
|
|
571
575
|
const providerRuntimeOptions = (task, options = {}, scope, link) => {
|
|
572
576
|
const guard = () => authorizeProviderDispatch(task.backend, scope, link);
|
|
@@ -868,13 +872,29 @@ export async function startAgent({
|
|
|
868
872
|
}
|
|
869
873
|
};
|
|
870
874
|
|
|
875
|
+
const automaticCodingRecoveries = new WeakSet();
|
|
871
876
|
const monitorCodingRuns = () => {
|
|
872
877
|
if (!desktopWorkAllowed()) return Promise.resolve();
|
|
873
|
-
if (!
|
|
878
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve();
|
|
874
879
|
if (codingRunMonitorInFlight) return codingRunMonitorInFlight;
|
|
875
880
|
codingRunMonitorInFlight = (async () => {
|
|
876
881
|
for (const run of registry.list()) {
|
|
877
|
-
if (!
|
|
882
|
+
if (canRecoverCodingRun(run) && !automaticCodingRecoveries.has(run) && (!run.snapshot.metadata?.automatic_recovery || codingRecoveryDue(run))) {
|
|
883
|
+
automaticCodingRecoveries.add(run);
|
|
884
|
+
void withUpdateWork(updateManager, async () => {
|
|
885
|
+
if (!sameRunScope(run.session, session) || run.apiOrigin !== apiOrigin) return;
|
|
886
|
+
const saved = { charter: run.snapshot.charter, metadata: { ...run.snapshot.metadata } };
|
|
887
|
+
if (!codingRecoveryLinksMatch(saved, await readProviderLinks(sessionRoot))) return;
|
|
888
|
+
await requireRunProvider(run, { automatic: true });
|
|
889
|
+
if (!codingRecoveryLinksMatch(saved, await readProviderLinks(sessionRoot))) return;
|
|
890
|
+
// Each run owns its operation promise; a slow repair must not
|
|
891
|
+
// prevent the monitor from advancing independent tasks.
|
|
892
|
+
await recoverLocalCodingRun(run, codingRuntimeOptions(run));
|
|
893
|
+
}).catch(error => reportStartupConnectionError(error, quiet))
|
|
894
|
+
.finally(() => automaticCodingRecoveries.delete(run));
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
if (!daemon || !["pr_ready_for_review", "merge_ready"].includes(run.status) || run.operation || run.child) continue;
|
|
878
898
|
try {
|
|
879
899
|
await (dependencies.recheckCodingRun || recheckAuthorizedCodingRun)(run);
|
|
880
900
|
} catch (error) {
|
|
@@ -887,7 +907,7 @@ export async function startAgent({
|
|
|
887
907
|
|
|
888
908
|
const startCodingRunMonitor = () => {
|
|
889
909
|
if (!desktopWorkAllowed()) return;
|
|
890
|
-
if (
|
|
910
|
+
if (codingRunMonitorTimer) return;
|
|
891
911
|
codingRunMonitorTimer = setInterval(() => {
|
|
892
912
|
void monitorCodingRuns().catch((error) => reportStartupConnectionError(error, quiet));
|
|
893
913
|
}, dependencies.codingRunMonitorIntervalMs || CODING_RUN_MONITOR_INTERVAL_MS);
|
|
@@ -1140,6 +1160,7 @@ export async function startAgent({
|
|
|
1140
1160
|
getHistoryScope: () => sqmHistoryScope(apiOrigin, session),
|
|
1141
1161
|
getRecoveryKey: (provider) => providerRecoveryRevisions[provider] ?? 0,
|
|
1142
1162
|
recordHistory: (scope, item) => sqmHistory.record(scope, item),
|
|
1163
|
+
readHistory: scope => sqmHistory.items(scope),
|
|
1143
1164
|
onHistoryError: () => { if (!quiet) console.error("Could not save the local SQM analysis history."); },
|
|
1144
1165
|
getProvider: () => !desktopWorkAllowed() || availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
|
|
1145
1166
|
(provider) => providerLinks[provider]?.connected === true && availableCapabilities?.backends?.[provider] === true &&
|
|
@@ -1197,7 +1218,7 @@ export async function startAgent({
|
|
|
1197
1218
|
|
|
1198
1219
|
if (req.method === "GET" && url.pathname === "/v1/auth/browser") {
|
|
1199
1220
|
if (req.headers.host !== `127.0.0.1:${port}`) { json(res, 403, { error: "Invalid local host." }); return; }
|
|
1200
|
-
const page = browserHandoffPage();
|
|
1221
|
+
const page = browserHandoffPage(apiOrigin);
|
|
1201
1222
|
res.writeHead(200, page.headers).end(page.html);
|
|
1202
1223
|
return;
|
|
1203
1224
|
}
|
|
@@ -1496,6 +1517,7 @@ export async function startAgent({
|
|
|
1496
1517
|
const previousSession = session ?? reclaimableSession;
|
|
1497
1518
|
const authorizedSession = {
|
|
1498
1519
|
token: String(body.token || ""),
|
|
1520
|
+
email: typeof body.email === "string" && body.email.length <= 254 ? body.email : null,
|
|
1499
1521
|
device_id: typeof body.device_id === "string" ? body.device_id : null,
|
|
1500
1522
|
api_origin: apiOrigin,
|
|
1501
1523
|
api_base_url: apiBaseUrl,
|
|
@@ -2353,7 +2375,7 @@ export async function startAgent({
|
|
|
2353
2375
|
: [],
|
|
2354
2376
|
required_checks: Array.isArray(body.required_checks) ? body.required_checks : [],
|
|
2355
2377
|
independent_review_required: body.independent_review_required === true,
|
|
2356
|
-
require_human_approval: body.require_human_approval
|
|
2378
|
+
require_human_approval: body.require_human_approval === true,
|
|
2357
2379
|
design_summary:
|
|
2358
2380
|
typeof body.design_summary === "string" ? body.design_summary : "",
|
|
2359
2381
|
design_review_approved_by:
|
|
@@ -2837,8 +2859,21 @@ export async function startAgent({
|
|
|
2837
2859
|
const { codex, claude } = deferStartupSynchronization
|
|
2838
2860
|
? providerStatuses
|
|
2839
2861
|
: await readProviderStatuses();
|
|
2862
|
+
const overviewSession = session;
|
|
2863
|
+
const overviewAccess = desktopAccess.snapshot();
|
|
2864
|
+
let overviewSqm = { state: 'unknown' };
|
|
2865
|
+
if (auth.state === 'signed_in' && !pendingOrganization) {
|
|
2866
|
+
try { overviewSqm = await readSqmConnectionObservation({ endpoint: process.env.SIMY_SQM_BUNDLE_URL?.trim() || new URL('sqm/knowledge-bundle', resolveWebApiBaseUrl(apiOrigin, overviewSession?.api_base_url)).href,
|
|
2867
|
+
accountId: overviewSession?.account_id || overviewSession?.auth_user_id || overviewSession?.device_id,
|
|
2868
|
+
organizationId: overviewSession?.organization_id || overviewSession?.org_id }, { root: process.env.SIMY_SQM_CACHE_DIR || join(sessionRoot, 'sqm') }); } catch { /* A missing endpoint must not block the status frame. */ }
|
|
2869
|
+
}
|
|
2870
|
+
// An organization switch/sign-out while reading local metadata must not
|
|
2871
|
+
// publish an old tenant's identity or SQM result in a fresh frame.
|
|
2872
|
+
const overview = connectionOverview({ webOrigin: apiOrigin, session, access: session === overviewSession ? overviewAccess : null,
|
|
2873
|
+
signedIn: session === overviewSession && auth.state === 'signed_in', switching: Boolean(pendingOrganization), sqm: overviewSqm });
|
|
2840
2874
|
return {
|
|
2841
2875
|
schemaVersion: 1,
|
|
2876
|
+
connectionOverview: overview,
|
|
2842
2877
|
sampledAt: new Date().toISOString(),
|
|
2843
2878
|
version: CLI_VERSION,
|
|
2844
2879
|
webOrigin: apiOrigin,
|
|
@@ -3049,7 +3084,7 @@ export async function startAgent({
|
|
|
3049
3084
|
expected_tests: [],
|
|
3050
3085
|
expected_evidence: [],
|
|
3051
3086
|
required_checks: [],
|
|
3052
|
-
require_human_approval: true,
|
|
3087
|
+
require_human_approval: input.require_human_approval === true,
|
|
3053
3088
|
must_not: [],
|
|
3054
3089
|
attachments: await referenceLocalAttachmentPaths(input.attachmentPaths || []),
|
|
3055
3090
|
};
|
package/src/auto-update.js
CHANGED
|
@@ -15,6 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { promisify } from "node:util";
|
|
17
17
|
|
|
18
|
+
import { daemonRestartCommand } from "./web-origin.js";
|
|
18
19
|
import { openExternalUrl } from "./browser.js";
|
|
19
20
|
import {
|
|
20
21
|
installMacosInstallerRelease,
|
|
@@ -1094,7 +1095,7 @@ export function autoUpdateHelpText({ installMode = null, webOrigin = null } = {}
|
|
|
1094
1095
|
return [
|
|
1095
1096
|
"Recommended installation (enables verified CLI auto-update):",
|
|
1096
1097
|
` npm install -g ${CLI_PACKAGE_NAME}`,
|
|
1097
|
-
|
|
1098
|
+
` ${daemonRestartCommand(webOrigin)}`,
|
|
1098
1099
|
].join("\n");
|
|
1099
1100
|
}
|
|
1100
1101
|
|
|
@@ -1131,7 +1132,7 @@ export function updateAction(version, { installMode = "global_npm", webOrigin }
|
|
|
1131
1132
|
return {
|
|
1132
1133
|
id: "install_global_cli",
|
|
1133
1134
|
label: "Install and restart SIMY",
|
|
1134
|
-
command: `npm install -g ${CLI_PACKAGE_NAME}@${version} &&
|
|
1135
|
+
command: `npm install -g ${CLI_PACKAGE_NAME}@${version} && ${daemonRestartCommand(webOrigin)}`,
|
|
1135
1136
|
};
|
|
1136
1137
|
}
|
|
1137
1138
|
|
package/src/browser-handoff.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeWebOrigin } from "./web-origin.js";
|
|
1
2
|
import { createDecipheriv, createHash } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
export const BROWSER_HANDOFF_TRANSPORT = "loopback_redirect_v1";
|
|
@@ -33,6 +34,14 @@ const copy = ja ? {
|
|
|
33
34
|
};
|
|
34
35
|
document.documentElement.lang = ja ? 'ja' : 'en';
|
|
35
36
|
const title = document.getElementById('title'), message = document.getElementById('message');
|
|
37
|
+
const environment = document.getElementById('environment');
|
|
38
|
+
const origin = environment.dataset.origin;
|
|
39
|
+
const host = new URL(origin).hostname;
|
|
40
|
+
const label = origin === 'https://app.simy.one' ? (ja ? '本番' : 'Production')
|
|
41
|
+
: origin === 'https://app-dev.simy.one' ? (ja ? '開発' : 'Development')
|
|
42
|
+
: ['localhost', '127.0.0.1', '[::1]'].includes(host) ? (ja ? 'ローカル開発' : 'Local development')
|
|
43
|
+
: (ja ? 'カスタムホスト' : 'Custom host');
|
|
44
|
+
environment.textContent = (ja ? '接続先環境' : 'Connection environment') + ': ' + label + ' · ' + origin;
|
|
36
45
|
title.textContent = copy.title; message.textContent = copy.waiting;
|
|
37
46
|
const handoff = new URLSearchParams(location.hash.slice(1)).get('handoff');
|
|
38
47
|
history.replaceState(null, '', location.pathname);
|
|
@@ -54,15 +63,17 @@ history.replaceState(null, '', location.pathname);
|
|
|
54
63
|
|
|
55
64
|
// A self-contained local page: credentials never enter the URL query, HTTP
|
|
56
65
|
// request, history or third-party resources. Only the encrypted fragment does.
|
|
57
|
-
const style = `:root{color-scheme:light dark;--bg:#ffffff;--card:#f6f5f4;--ink:#0d0d0d;--muted:#615d59;--line:#e6e6e6;--accent:#0075de} @media(prefers-color-scheme:dark){:root{--bg:#1a1918;--card:#252320;--ink:#f5f3f0;--muted:#b8b3ad;--line:#31302f;--accent:#3b9eff}}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:24px;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,sans-serif}main{max-width:480px;width:100%;padding:32px;border:1px solid var(--line);border-radius:12px;background:var(--card)}.brand{color:var(--accent);font-weight:600;font-size:14px}h1{font-size:24px;font-weight:600;margin:16px 0}p{color:var(--muted);margin:0}body[data-state=failed] h1{color:var(--ink)}`;
|
|
66
|
+
const style = `:root{color-scheme:light dark;--bg:#ffffff;--card:#f6f5f4;--ink:#0d0d0d;--muted:#615d59;--line:#e6e6e6;--accent:#0075de} @media(prefers-color-scheme:dark){:root{--bg:#1a1918;--card:#252320;--ink:#f5f3f0;--muted:#b8b3ad;--line:#31302f;--accent:#3b9eff}}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:24px;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,sans-serif}main{max-width:480px;width:100%;padding:32px;border:1px solid var(--line);border-radius:12px;background:var(--card)}.brand{color:var(--accent);font-weight:600;font-size:14px}h1{font-size:24px;font-weight:600;margin:16px 0}p{color:var(--muted);margin:0}#environment{margin-top:16px;font-size:14px;overflow-wrap:anywhere}body[data-state=failed] h1{color:var(--ink)}`;
|
|
58
67
|
const hash = value => createHash("sha256").update(value).digest("base64");
|
|
59
|
-
export function browserHandoffPage() {
|
|
68
|
+
export function browserHandoffPage(webOrigin) {
|
|
69
|
+
const origin = normalizeWebOrigin(webOrigin);
|
|
70
|
+
const escapedOrigin = origin.replace(/[&<>"']/g, value => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[value]);
|
|
60
71
|
return {
|
|
61
72
|
headers: {
|
|
62
73
|
"Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store",
|
|
63
74
|
"Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff",
|
|
64
75
|
"Content-Security-Policy": `default-src 'none'; script-src 'sha256-${hash(script)}'; style-src 'sha256-${hash(style)}'; connect-src 'self'; img-src data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`,
|
|
65
76
|
},
|
|
66
|
-
html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SIMY connection</title><link rel="icon" href="data:,"><style>${style}</style></head><body><main aria-live="polite" aria-atomic="true"><div class="brand">SIMY</div><h1 id="title">Connecting to SIMY</h1><p id="message">Keep SIMY open while connecting.</p><noscript>JavaScript is required. Return to SIMY and reconnect using a browser with JavaScript enabled.</noscript></main><script>${script}</script></body></html>`,
|
|
77
|
+
html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SIMY connection</title><link rel="icon" href="data:,"><style>${style}</style></head><body><main aria-live="polite" aria-atomic="true"><div class="brand">SIMY</div><h1 id="title">Connecting to SIMY</h1><p id="message">Keep SIMY open while connecting.</p><p id="environment" data-origin="${escapedOrigin}">Connection environment: ${escapedOrigin}</p><noscript>JavaScript is required. Return to SIMY and reconnect using a browser with JavaScript enabled.</noscript></main><script>${script}</script></body></html>`,
|
|
67
78
|
};
|
|
68
79
|
}
|
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.25";
|
|
18
18
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
19
19
|
|
|
20
20
|
export function narrativeImageCapabilityMetadata() {
|
|
@@ -118,7 +118,9 @@ const text = (value, max = 4000) => typeof value === 'string' ? value.trim().sli
|
|
|
118
118
|
const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
|
|
119
119
|
export function canUpgradeDelivery(run) {
|
|
120
120
|
return Boolean(run && !run.managed && text(run.id) && text(run.objective) && Number.isFinite(run.startedAt) &&
|
|
121
|
-
Number.isSafeInteger(run.maxFailures) && run.maxFailures > 0 &&
|
|
121
|
+
Number.isSafeInteger(run.maxFailures) && run.maxFailures > 0 &&
|
|
122
|
+
(['active', 'paused', 'recovery_required'].includes(run.state) ||
|
|
123
|
+
run.state === 'blocked' && run.blocker === 'external_dependency'));
|
|
122
124
|
}
|
|
123
125
|
const contextText = (value, max = 500) => text(value, max).replace(/\b(?:sk|key)-[A-Za-z0-9_-]{12,}\b|\bBearer\s+\S+|\b(?:password|token|api[_-]?key)\s*[:=]\s*\S+/gi, '[redacted]');
|
|
124
126
|
export function recordPath(root, provider, session) {
|
|
@@ -17,7 +17,7 @@ function classifyBlockers(findings) {
|
|
|
17
17
|
const kind = ["machine", "human", "external"].includes(finding.blocker_kind)
|
|
18
18
|
? finding.blocker_kind
|
|
19
19
|
: finding.repairability === "external" ? "external"
|
|
20
|
-
:
|
|
20
|
+
: "machine";
|
|
21
21
|
blockers[kind].push({
|
|
22
22
|
code: text(finding.code) || "OUTCOME_UNPROVEN",
|
|
23
23
|
target: text(finding.target) || null,
|
|
@@ -11,9 +11,10 @@ export const detachmentRoot = () => resolve(process.env.SIMY_HOME?.trim() || joi
|
|
|
11
11
|
export const rootFromDelivery = root => basename(resolve(root)) === 'delivery-hooks' ? dirname(resolve(root)) : resolve(root);
|
|
12
12
|
const path = root => join(resolve(root), 'update-detachment', 'gate.json');
|
|
13
13
|
function read(root) {
|
|
14
|
-
const state = readState(path(root), { versions: [1, 2] }) || { schemaVersion: 1, revision: 0, generation: 0, connection: 'connected', attempts: {}, receipts: {}, events: {} };
|
|
14
|
+
const state = readState(path(root), { versions: [1, 2, 3] }) || { schemaVersion: 1, revision: 0, generation: 0, connection: 'connected', attempts: {}, receipts: {}, events: {} };
|
|
15
15
|
if (!Number.isSafeInteger(state.generation) || !Number.isSafeInteger(state.revision) || !CONNECTION_STATES.includes(state.connection) || !['attempts', 'receipts', 'events'].every(key => isMap(state[key])) || (state.hookOwners !== undefined && !isMap(state.hookOwners)) || !Object.values(state.attempts).every(attempt => isMap(attempt) && typeof attempt.id === 'string' && Number.isSafeInteger(attempt.generation) && ['intent','sending','sent','provider_identified','result_saved','confirmed'].includes(attempt.phase))) fail('invalid_state');
|
|
16
|
-
if ((state.schemaVersion
|
|
16
|
+
if ((state.schemaVersion >= 2 && ![2, 3].includes(state.minimumDispatchProtocol)) || (state.minimumDispatchProtocol !== undefined && ![2, 3].includes(state.minimumDispatchProtocol))) fail('writer_protocol_incompatible');
|
|
17
|
+
validateRecoveryFences(state);
|
|
17
18
|
validateTaskFences(state, root);
|
|
18
19
|
return state;
|
|
19
20
|
}
|
|
@@ -31,7 +32,7 @@ export function transitionConnection({ root = detachmentRoot(), expectedRevision
|
|
|
31
32
|
if (state.revision !== expectedRevision) fail('revision_conflict');
|
|
32
33
|
const allowed = { connected: ['detaching'], ready: ['connected', 'detaching'], detaching: ['detached', 'recovery_required'], detached: ['reconciling'], reconciling: ['ready', 'recovery_required'], recovery_required: ['reconciling', 'detaching'] };
|
|
33
34
|
if (!allowed[state.connection]?.includes(connection)) fail('invalid_transition');
|
|
34
|
-
if (connection === 'detaching') { state.generation++; state.schemaVersion = 2; state.minimumDispatchProtocol = DISPATCH_PROTOCOL; }
|
|
35
|
+
if (connection === 'detaching') { state.generation++; state.schemaVersion = Math.max(2, state.schemaVersion); state.minimumDispatchProtocol = DISPATCH_PROTOCOL; }
|
|
35
36
|
state.connection = connection; state.revision++;
|
|
36
37
|
if (eventId) state.events[digest(eventId)] = { revision: state.revision };
|
|
37
38
|
atomicSave(path(root), state); return state;
|
|
@@ -221,7 +222,7 @@ export function prepareUpgradeFence({ root = detachmentRoot(), expectedRevision,
|
|
|
221
222
|
const state = read(root); validateWriters(state, writers);
|
|
222
223
|
if (state.revision !== expectedRevision) fail('revision_conflict');
|
|
223
224
|
if (!['connected', 'ready', 'recovery_required'].includes(state.connection)) fail('invalid_transition');
|
|
224
|
-
state.generation++; state.schemaVersion = 2; state.minimumDispatchProtocol = DISPATCH_PROTOCOL;
|
|
225
|
+
state.generation++; state.schemaVersion = Math.max(2, state.schemaVersion); state.minimumDispatchProtocol = DISPATCH_PROTOCOL;
|
|
225
226
|
state.connection = 'detaching'; state.revision++; atomicSave(path(root), state); return state;
|
|
226
227
|
});
|
|
227
228
|
}
|
|
@@ -252,6 +253,12 @@ function validateTaskFences(state, root) {
|
|
|
252
253
|
}
|
|
253
254
|
}
|
|
254
255
|
function assertTaskAdmissionState(state, { taskId, identity = null }) {
|
|
256
|
+
for (const item of Object.values(state.recoveryFences?.items || {})) {
|
|
257
|
+
if (item.taskHash === digest(taskId) || identity && (item.identityHash === digest(identity) ||
|
|
258
|
+
item.sessions.some(target => target.provider === identity.provider && target.session === identity.session))) {
|
|
259
|
+
fail('dispatch_history_quarantined');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
255
262
|
const records = state.taskFences?.records || {};
|
|
256
263
|
if (!identity) {
|
|
257
264
|
if (Object.values(records).some(item => item.taskId === taskId)) fail('task_admission_identity_required');
|
|
@@ -276,7 +283,9 @@ export function readTaskAdmission({ root = detachmentRoot(), identity, taskId })
|
|
|
276
283
|
identity = storedFenceIdentity(identity, root);
|
|
277
284
|
if (identity.root !== realpathSync(root)) fail('identity_root_mismatch');
|
|
278
285
|
const state = read(root), record = state.taskFences?.records[fenceKey(identity, taskId)] || null;
|
|
279
|
-
|
|
286
|
+
let historyAdmitted = true;
|
|
287
|
+
try { assertTaskAdmissionState(state, {taskId, identity}); } catch (error) { if (error.code !== 'dispatch_history_quarantined' && error.code !== 'task_admission_blocked') throw error; historyAdmitted = false; }
|
|
288
|
+
return { revision: state.revision, generation: state.generation, record, canResume: historyAdmitted && ['connected', 'ready'].includes(state.connection) && (!record || record.status === 'admitted' && record.generation === state.generation && admissionWriterMatches(state, record)) };
|
|
280
289
|
}
|
|
281
290
|
function checkAdmissionOwner(state, owner, identity, taskId, expectedRevision) {
|
|
282
291
|
if (!owner || owner.root !== identity.root || owner.protocol !== DISPATCH_PROTOCOL || owner.generation !== state.generation) fail('owner_fenced');
|
|
@@ -295,7 +304,7 @@ export function setTaskAdmissionFence({ owner, identity, taskId, expectedRevisio
|
|
|
295
304
|
state.taskFences ||= { version: 1, records: {} };
|
|
296
305
|
state.taskFences.records[key] = { version: 1, identity, identityHash: digest(identity), taskId, taskHash: digest(taskId), generation: state.generation, revision: (previous?.revision || 0) + 1, status: 'blocked', reason: { ...reason }, createdAt: Date.now(), admission: null };
|
|
297
306
|
// v2 writers validate minimumDispatchProtocol and cannot ignore taskFences.
|
|
298
|
-
state.schemaVersion = 2; state.minimumDispatchProtocol = 3; state.revision++;
|
|
307
|
+
state.schemaVersion = Math.max(2, state.schemaVersion); state.minimumDispatchProtocol = 3; state.revision++;
|
|
299
308
|
atomicSave(path(identity.root), state); return state;
|
|
300
309
|
});
|
|
301
310
|
}
|
|
@@ -324,3 +333,77 @@ export async function admitTaskDispatch({ owner, identity, taskId, expectedRevis
|
|
|
324
333
|
atomicSave(path(identity.root), state); return state;
|
|
325
334
|
} finally { release(); }
|
|
326
335
|
}
|
|
336
|
+
|
|
337
|
+
// Historical evidence without a provable result is retained, never replayed or
|
|
338
|
+
// called completed. A schema fence makes every pre-recovery binary fail closed.
|
|
339
|
+
function validateRecoveryFences(state) {
|
|
340
|
+
if (state.schemaVersion !== 3 && state.recoveryFences === undefined) return;
|
|
341
|
+
const recovery = state.recoveryFences;
|
|
342
|
+
if (state.schemaVersion !== 3 || !recovery || recovery.version !== 1 || !isMap(recovery.items) ||
|
|
343
|
+
typeof recovery.auditRef !== 'string' || !/^[a-f0-9]{64}$/.test(recovery.auditDigest || '')) fail('recovery_fence_invalid');
|
|
344
|
+
for (const [key, item] of Object.entries(recovery.items)) {
|
|
345
|
+
const attempt = state.attempts[key];
|
|
346
|
+
if (!attempt || item.taskHash !== attempt.taskHash || item.identityHash !== attempt.identityHash ||
|
|
347
|
+
!/^[a-f0-9]{64}$/.test(item.attemptDigest || '') || !Array.isArray(item.sessions) ||
|
|
348
|
+
item.sessions.some(target => !['codex','claude'].includes(target.provider) || typeof target.session !== 'string' || !target.session)) fail('recovery_fence_invalid');
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
export function unresolvedDispatchAttempts(state) {
|
|
352
|
+
return Object.entries(state.attempts).filter(([key, attempt]) => attempt.phase !== 'confirmed' &&
|
|
353
|
+
state.recoveryFences?.items?.[key]?.attemptDigest !== digest(attempt)).map(([, attempt]) => attempt);
|
|
354
|
+
}
|
|
355
|
+
export function reconcileDispatchHistory({root = detachmentRoot(), expectedRevision, expectedGeneration, auditRef, auditDigest}) {
|
|
356
|
+
// Exclude a live update/reconnect owner as well as simultaneous dispatches.
|
|
357
|
+
const release = acquireLock(join(root, 'update-detachment', 'coordinator'));
|
|
358
|
+
try { return withLock(path(root), () => {
|
|
359
|
+
const state = read(root);
|
|
360
|
+
if (state.revision !== expectedRevision || state.generation !== expectedGeneration) fail('revision_conflict');
|
|
361
|
+
if (state.connection !== 'recovery_required') fail('reconciliation_required');
|
|
362
|
+
const auditPath = resolve(auditRef || '');
|
|
363
|
+
if (!auditPath.startsWith(resolve(root, 'update-detachment', 'recovery') + '/') || realpathSync(auditPath) !== auditPath) fail('recovery_audit_invalid');
|
|
364
|
+
const bytes = readFileSync(auditPath);
|
|
365
|
+
if (createHash('sha256').update(bytes).digest('hex') !== auditDigest) fail('recovery_audit_changed');
|
|
366
|
+
const audit = JSON.parse(bytes);
|
|
367
|
+
if (audit.version !== 1 || audit.gateDigest !== digest(state) || !Array.isArray(audit.items) ||
|
|
368
|
+
audit.items.length !== Object.keys(state.attempts).length) fail('recovery_audit_invalid');
|
|
369
|
+
const items = {}, seen = new Set(), settled = [];
|
|
370
|
+
for (const item of audit.items) {
|
|
371
|
+
const key = digest(item.attemptId), attempt = state.attempts[key];
|
|
372
|
+
if (!attempt || seen.has(key) || item.attemptDigest !== digest(attempt)) fail('recovery_attempt_changed');
|
|
373
|
+
seen.add(key);
|
|
374
|
+
if (item.disposition === 'already_confirmed' && attempt.phase === 'confirmed') continue;
|
|
375
|
+
if (item.disposition === 'saved_terminal_result') {
|
|
376
|
+
const evidence = item.evidence;
|
|
377
|
+
if (!evidence || !attempt.provenance || evidence.ref !== attempt.provenance.recordRef ||
|
|
378
|
+
!Object.values(state.receipts).some(receipt => receipt.attemptId === attempt.id && receipt.generation === attempt.generation && receipt.resultRef === evidence.ref)) fail('recovery_result_unbound');
|
|
379
|
+
if (!resolve(evidence.ref).startsWith(resolve(root, 'direct-tasks') + '/') || realpathSync(evidence.ref) !== evidence.ref) fail('recovery_result_scope');
|
|
380
|
+
const source = readFileSync(evidence.ref);
|
|
381
|
+
if (createHash('sha256').update(source).digest('hex') !== evidence.sha256) fail('recovery_result_changed');
|
|
382
|
+
const result = JSON.parse(source), match = /^(.*):provider:([1-9][0-9]*)$/.exec(attempt.id);
|
|
383
|
+
if (!match || result.id !== match[1] || digest(result.id) !== attempt.taskHash ||
|
|
384
|
+
result.id !== attempt.provenance.taskId || result.invocation_count !== Number(match[2]) ||
|
|
385
|
+
!['succeeded','failed','stopped'].includes(result.status)) fail('recovery_result_unbound');
|
|
386
|
+
settled.push(attempt);
|
|
387
|
+
} else if (item.disposition === 'retain_blocked_unknown') {
|
|
388
|
+
const sessions = [];
|
|
389
|
+
if (attempt.provenance?.providerSession) sessions.push({provider:attempt.provenance.context.provider,session:attempt.provenance.providerSession});
|
|
390
|
+
// Recover the parent identity only from its original canonical record.
|
|
391
|
+
const ref = attempt.provenance?.recordRef;
|
|
392
|
+
if (ref?.startsWith(resolve(root, 'delivery-hooks') + '/')) {
|
|
393
|
+
try { const record = JSON.parse(readFileSync(ref));
|
|
394
|
+
if (record.run?.managed?.taskId === attempt.provenance.taskId && record.provider === attempt.provenance.context.provider &&
|
|
395
|
+
basename(ref) === createHash('sha256').update(record.provider + '\0' + record.session).digest('hex') + '.json') sessions.push({provider:record.provider,session:record.session});
|
|
396
|
+
} catch { /* Missing parent never authorizes an inferred identity. */ }
|
|
397
|
+
}
|
|
398
|
+
items[key] = {taskHash:attempt.taskHash,identityHash:attempt.identityHash,attemptDigest:digest(attempt),sessions};
|
|
399
|
+
} else fail('recovery_disposition_invalid');
|
|
400
|
+
}
|
|
401
|
+
for (const attempt of settled) attempt.phase = 'confirmed';
|
|
402
|
+
state.schemaVersion = 3; state.minimumDispatchProtocol = 3;
|
|
403
|
+
state.generation++; state.revision++;
|
|
404
|
+
state.recoveryFences = {version:1,auditRef:auditPath,auditDigest,items};
|
|
405
|
+
validateRecoveryFences(state);
|
|
406
|
+
atomicSave(path(root), state);
|
|
407
|
+
return {settled:settled.length,blocked:Object.keys(items).length,revision:state.revision,generation:state.generation,replayAttempts:false};
|
|
408
|
+
}); } finally { release(); }
|
|
409
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { budgetState } from './orchestrator/budget.js';
|
|
2
|
+
|
|
3
|
+
const BASE_DELAY = 5 * 60_000;
|
|
4
|
+
const MAX_DELAY = 30 * 60_000;
|
|
5
|
+
const MACHINE_STOPS = new Set(['outcome_progress_stalled', 'retry_circuit_open']);
|
|
6
|
+
|
|
7
|
+
export function canRecoverCodingRun(run) {
|
|
8
|
+
const snapshot = run.snapshot;
|
|
9
|
+
return run.status === 'blocked' && !run.stopRequested &&
|
|
10
|
+
!['paused', 'stopped', 'stopping'].includes(run.controlState) &&
|
|
11
|
+
!snapshot.metadata?.local_stop_requested && !run.operation && !run.child &&
|
|
12
|
+
snapshot.final_audit?.requires_human === false &&
|
|
13
|
+
MACHINE_STOPS.has(snapshot.events?.at(-1)?.detail?.code) &&
|
|
14
|
+
!budgetState(snapshot).exhausted;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function scheduleCodingRecovery(snapshot, now = Date.now()) {
|
|
18
|
+
const count = (snapshot.metadata?.automatic_recovery?.count || 0) + 1;
|
|
19
|
+
snapshot.metadata ??= {};
|
|
20
|
+
snapshot.metadata.automatic_recovery = {
|
|
21
|
+
count,
|
|
22
|
+
next_retry_at: now + Math.min(MAX_DELAY, BASE_DELAY * 2 ** Math.min(count - 1, 3)),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function codingRecoveryDue(run, now = Date.now()) {
|
|
27
|
+
return canRecoverCodingRun(run) &&
|
|
28
|
+
Number.isFinite(run.snapshot.metadata?.automatic_recovery?.next_retry_at) &&
|
|
29
|
+
now >= run.snapshot.metadata.automatic_recovery.next_retry_at;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function codingRecoveryLinksMatch(snapshot, links) {
|
|
33
|
+
return [snapshot.charter.backend, snapshot.charter.audit_backend].filter(Boolean).every(provider => {
|
|
34
|
+
const link = links[provider];
|
|
35
|
+
const saved = snapshot.metadata?.provider_link_epochs;
|
|
36
|
+
return link?.connected === true && (saved && Object.hasOwn(saved, provider)
|
|
37
|
+
? saved[provider] === link.epoch
|
|
38
|
+
: link.epoch === null && link.requiresConsent === false);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { defaultSessionRoot } from './session-store.js';
|
|
6
|
+
|
|
7
|
+
const safeText = (value, limit = 160) => typeof value === 'string' ? value.replace(/[\x00-\x1f\x7f]/g, '').slice(0, limit) : null;
|
|
8
|
+
const scopeKey = ({ endpoint, accountId, organizationId }) => {
|
|
9
|
+
if (!endpoint || !accountId || !organizationId) return null;
|
|
10
|
+
try { const url = new URL(endpoint); url.username = ''; url.password = ''; url.search = ''; url.hash = '';
|
|
11
|
+
return createHash('sha256').update(JSON.stringify([url.href, accountId, organizationId])).digest('hex');
|
|
12
|
+
} catch { return null; }
|
|
13
|
+
};
|
|
14
|
+
const observationPath = (scope, root) => { const key = scopeKey(scope); return key ? join(root, 'connection-observations', `${key}.json`) : null; };
|
|
15
|
+
|
|
16
|
+
// Only metadata of an actual online bundle lookup; never credentials, rules,
|
|
17
|
+
// executable checks or inferred cross-organization registrations.
|
|
18
|
+
export async function recordSqmConnectionObservation(scope, result, { root = process.env.SIMY_SQM_CACHE_DIR || join(defaultSessionRoot(), 'sqm'), now = Date.now() } = {}) {
|
|
19
|
+
const target = observationPath(scope, root);
|
|
20
|
+
if (!target || !['cloud', 'cache_revalidated', 'unavailable'].includes(result?.source)) return;
|
|
21
|
+
const bundle = result.bundle;
|
|
22
|
+
const verified = bundle?.organization_id === scope.organizationId && bundle?.system_scope?.organization_id === scope.organizationId;
|
|
23
|
+
const repository = safeText(scope.repository, 200);
|
|
24
|
+
if (!repository || !/^[^/\s]+\/[^/\s]+$/.test(repository)) return;
|
|
25
|
+
const value = { version: 1, state: verified ? 'verified' : 'unavailable', repository,
|
|
26
|
+
checkedAt: new Date(now).toISOString(), expiresAt: verified ? bundle.expires_at : null };
|
|
27
|
+
const temp = `${target}.${randomUUID()}.tmp`;
|
|
28
|
+
try { await mkdir(join(root, 'connection-observations'), { recursive: true, mode: 0o700 });
|
|
29
|
+
await writeFile(temp, JSON.stringify(value), { mode: 0o600 }); await rename(temp, target);
|
|
30
|
+
} finally { await rm(temp, { force: true }); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function readSqmConnectionObservation(scope, { root = process.env.SIMY_SQM_CACHE_DIR || join(defaultSessionRoot(), 'sqm'), now = Date.now() } = {}) {
|
|
34
|
+
const target = observationPath(scope, root);
|
|
35
|
+
if (!target) return { state: 'unknown' };
|
|
36
|
+
try {
|
|
37
|
+
const raw = await readFile(target, 'utf8'); if (raw.length > 4096) return { state: 'unknown' };
|
|
38
|
+
const v = JSON.parse(raw), at = Date.parse(v.checkedAt);
|
|
39
|
+
if (v.version !== 1 || !['verified', 'unavailable'].includes(v.state) || !Number.isFinite(at) || at > now ||
|
|
40
|
+
typeof v.repository !== 'string' || !/^[^/\s]+\/[^/\s]+$/.test(v.repository) || v.repository.length > 200) return { state: 'unknown' };
|
|
41
|
+
const current = now - at <= 5 * 60 * 1000 && (v.state !== 'verified' || Date.parse(v.expiresAt) > now);
|
|
42
|
+
return { state: current ? v.state : 'previous', repository: safeText(v.repository, 200), checkedAt: v.checkedAt };
|
|
43
|
+
} catch { return { state: 'unknown' }; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function connectionEnvironment(origin) {
|
|
47
|
+
try { const url = new URL(origin); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') return { kind: 'unknown', origin: null };
|
|
48
|
+
return { kind: url.origin === 'https://app.simy.one' ? 'production' : url.origin === 'https://app-dev.simy.one' ? 'development' : 'custom', origin: url.origin };
|
|
49
|
+
} catch { return { kind: 'unknown', origin: null }; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function connectionOverview({ webOrigin, session, access, signedIn, switching, sqm, deviceName = hostname() }) {
|
|
53
|
+
const scopeReady = signedIn && !switching && session?.organization_id && access?.organizationId === session.organization_id && access?.state === 'ready';
|
|
54
|
+
return { device: { name: safeText(deviceName) || null }, environment: connectionEnvironment(webOrigin),
|
|
55
|
+
organization: { name: signedIn && access?.organizationId === session?.organization_id ? safeText(access?.organizationName) : null,
|
|
56
|
+
state: switching ? 'switching' : scopeReady ? 'ready' : 'unknown' },
|
|
57
|
+
sqm: scopeReady ? (sqm || { state: 'unknown' }) : { state: 'unknown' } };
|
|
58
|
+
}
|
package/src/console/app.js
CHANGED
|
@@ -1147,6 +1147,7 @@ function statusLabel(run) {
|
|
|
1147
1147
|
if (run.controlState === "stopping") return "Stopping";
|
|
1148
1148
|
if (run.controlState === "stopped" || run.status === "stopped") return "Stopped";
|
|
1149
1149
|
if (run.status === "pr_ready_for_review") return "PR ready · Review needed";
|
|
1150
|
+
if (run.status === 'blocked' && run.snapshot?.final_audit?.requires_human === false) return 'Blocked';
|
|
1150
1151
|
if (run.controlState === "waiting_human" || run.status === "waiting_human") {
|
|
1151
1152
|
return "Waiting for you";
|
|
1152
1153
|
}
|
|
@@ -1181,6 +1182,10 @@ function nextActionForRun(run, repositoryMissing = false) {
|
|
|
1181
1182
|
text: "Review the final evidence and merge the pull request when ready.",
|
|
1182
1183
|
};
|
|
1183
1184
|
}
|
|
1185
|
+
const latestRecovery = [...(run.snapshot?.events || [])].reverse().find(event => event.detail?.recovery)?.detail.recovery;
|
|
1186
|
+
if (run.status === "blocked" && ['machine_recovery_pending', 'machine_recovery_exhausted'].includes(latestRecovery?.reason_code) && latestRecovery.requires_user_action === false) {
|
|
1187
|
+
return { tone: latestRecovery.reason_code === 'machine_recovery_pending' ? 'yellow' : 'red', text: latestRecovery.user_message };
|
|
1188
|
+
}
|
|
1184
1189
|
if (run.controlState === "waiting_human" || run.status === "waiting_human") {
|
|
1185
1190
|
return { tone: "yellow", text: "Press i, type your guidance or approval, then press Enter." };
|
|
1186
1191
|
}
|