@awak-app/simy-cli 0.4.0 → 0.5.0
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 +44 -13
- package/package.json +1 -1
- package/src/auto-update.js +256 -9
- package/src/cli-contract.js +1 -1
- package/src/index.js +138 -67
- package/src/orchestrator/completion-contract.js +15 -1
- package/src/sqm/bundle-store.js +42 -0
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@ npm install -g @awak-app/simy-cli
|
|
|
9
9
|
simy --daemon
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
This is the recommended installation because the
|
|
13
|
-
|
|
12
|
+
This is the recommended installation because the CLI can safely install patch
|
|
13
|
+
and minor releases, and the daemon restarts only when no Agentic Loop task is active. To run
|
|
14
14
|
the interactive chat after installation, use `simy`.
|
|
15
15
|
|
|
16
16
|
For a one-time evaluation or source development, these modes remain supported:
|
|
@@ -135,23 +135,54 @@ available for older Web clients.
|
|
|
135
135
|
|
|
136
136
|
## CLI updates
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
patch releases
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
138
|
+
Every command checks the stable npm release before dispatch, including `simy`,
|
|
139
|
+
`simy --daemon`, `simy sqm min-check`, and `simy sqm check`. Globally installed
|
|
140
|
+
patch releases update silently; minor releases update automatically with one
|
|
141
|
+
short notice. The updated executable is verified and then re-executes the exact
|
|
142
|
+
original argv once. A process-wide nonce/version guard prevents update loops.
|
|
143
|
+
Major releases are never installed at startup: `simy update` asks for explicit
|
|
144
|
+
confirmation (`simy update --yes` is available for non-interactive automation).
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
Use these commands to inspect or apply an update explicitly:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
simy update --check
|
|
150
|
+
simy update
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
A running daemon also checks hourly. Before installing, its strict idle gate
|
|
154
|
+
confirms there is no active, paused, blocked, or human-waiting task, executor
|
|
155
|
+
child process, or pending ledger write. It stops accepting new work during
|
|
156
|
+
installation, verifies the installed package, starts the new daemon, verifies
|
|
157
|
+
its version and local health handoff, and only then closes the old process.
|
|
158
|
+
Fixed-port daemons retain the manual restart path.
|
|
159
|
+
|
|
160
|
+
Only a global npm installation may be changed automatically. `npx`, a source
|
|
161
|
+
checkout, and a normal dependency installation are read-only and display an
|
|
162
|
+
exact recovery command instead:
|
|
149
163
|
|
|
150
164
|
```bash
|
|
151
165
|
npm install -g @awak-app/simy-cli@<version> && simy --daemon
|
|
152
166
|
```
|
|
153
167
|
|
|
154
|
-
|
|
168
|
+
If installation or startup verification fails, the updater restores the
|
|
169
|
+
current exact version when possible and prints both the pinned recovery command
|
|
170
|
+
and the target command. Use `--no-auto-update` or `SIMY_AUTO_UPDATE=0` to disable
|
|
171
|
+
all update checks and writes for any command.
|
|
172
|
+
|
|
173
|
+
CLI `0.3.3` predates this startup updater and cannot use it to upgrade itself.
|
|
174
|
+
The last required manual bridge is:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
npm install -g @awak-app/simy-cli@0.4.0
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
From `0.4.0` onward, subsequent compatible minor releases update automatically.
|
|
181
|
+
When SQM Cloud returns structured `incompatible_cli`, the CLI runs the same
|
|
182
|
+
verified updater and re-executes the original SQM command at most once. An SQM
|
|
183
|
+
min-check or full check pins its CLI version and signed knowledge bundle for the
|
|
184
|
+
entire execution; no update is applied mid-check.
|
|
185
|
+
|
|
155
186
|
The current update state is also exposed by `GET /v1/health` as `cli_update`.
|
|
156
187
|
Auto-update E2E screenshots are generated locally under
|
|
157
188
|
`.artifacts/cli-auto-update/`; this ignored directory must not be committed.
|
package/package.json
CHANGED
package/src/auto-update.js
CHANGED
|
@@ -19,6 +19,8 @@ const execFileAsync = promisify(execFile);
|
|
|
19
19
|
|
|
20
20
|
export const CLI_PACKAGE_NAME = "@awak-app/simy-cli";
|
|
21
21
|
export const AUTO_UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1_000;
|
|
22
|
+
export const UPDATE_REEXEC_ENV = "SIMY_UPDATE_REEXEC_VERSION";
|
|
23
|
+
export const INCOMPATIBLE_RETRY_ENV = "SIMY_UPDATE_INCOMPATIBLE_RETRY";
|
|
22
24
|
const AUTO_UPDATE_INITIAL_DELAY_MS = 15_000;
|
|
23
25
|
const AUTO_UPDATE_IDLE_RETRY_MS = 30_000;
|
|
24
26
|
const UPDATE_LOCK_STALE_MS = 30 * 60 * 1_000;
|
|
@@ -166,10 +168,8 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
166
168
|
return this.snapshot();
|
|
167
169
|
}
|
|
168
170
|
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
targetVersion,
|
|
172
|
-
);
|
|
171
|
+
const change = classifyVersionChange(this.#options.currentVersion, targetVersion);
|
|
172
|
+
const automatic = this.#snapshot.automatic && ["patch", "minor"].includes(change);
|
|
173
173
|
const action = updateAction(targetVersion);
|
|
174
174
|
if (!automatic) {
|
|
175
175
|
this.#clearIdleTimer();
|
|
@@ -256,7 +256,12 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
256
256
|
return this.snapshot();
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
await
|
|
259
|
+
await installAndVerifyVersion({
|
|
260
|
+
currentVersion: this.#options.currentVersion,
|
|
261
|
+
targetVersion,
|
|
262
|
+
installVersion: this.#options.installVersion,
|
|
263
|
+
readInstalledVersion: this.#options.readInstalledVersion,
|
|
264
|
+
});
|
|
260
265
|
if (typeof this.#restartDaemon !== "function") {
|
|
261
266
|
throw new Error("The daemon restart handoff is unavailable.");
|
|
262
267
|
}
|
|
@@ -343,8 +348,11 @@ export async function createCliAutoUpdater({
|
|
|
343
348
|
installVersion:
|
|
344
349
|
dependencies.installVersion ||
|
|
345
350
|
((version) => installGlobalNpmVersion(version, { execFileCommand: dependencies.execFileCommand })),
|
|
351
|
+
readInstalledVersion:
|
|
352
|
+
dependencies.readInstalledVersion ||
|
|
353
|
+
(() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand })),
|
|
346
354
|
acquireUpdateLock:
|
|
347
|
-
dependencies.acquireUpdateLock || (() =>
|
|
355
|
+
dependencies.acquireUpdateLock || (() => acquireGlobalUpdateLock(stateRoot, dependencies)),
|
|
348
356
|
logger: dependencies.logger || console.log,
|
|
349
357
|
quiet: Boolean(interactive),
|
|
350
358
|
now: dependencies.now || Date.now,
|
|
@@ -416,6 +424,214 @@ export async function installGlobalNpmVersion(
|
|
|
416
424
|
);
|
|
417
425
|
}
|
|
418
426
|
|
|
427
|
+
export async function readGlobalNpmVersion({ execFileCommand = execFileAsync } = {}) {
|
|
428
|
+
const { stdout } = await execFileCommand("npm", ["root", "--global"], {
|
|
429
|
+
timeout: 10_000,
|
|
430
|
+
encoding: "utf8",
|
|
431
|
+
});
|
|
432
|
+
const packagePath = path.join(String(stdout || "").trim(), ...CLI_PACKAGE_NAME.split("/"), "package.json");
|
|
433
|
+
return (await readCliPackageInfo(path.dirname(packagePath))).version;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export async function installAndVerifyVersion({
|
|
437
|
+
currentVersion,
|
|
438
|
+
targetVersion,
|
|
439
|
+
installVersion = installGlobalNpmVersion,
|
|
440
|
+
readInstalledVersion = readGlobalNpmVersion,
|
|
441
|
+
}) {
|
|
442
|
+
try {
|
|
443
|
+
await installVersion(targetVersion);
|
|
444
|
+
const installedVersion = await readInstalledVersion();
|
|
445
|
+
if (installedVersion !== targetVersion) {
|
|
446
|
+
throw new Error(`npm reported success, but installed SIMY ${installedVersion}; expected ${targetVersion}.`);
|
|
447
|
+
}
|
|
448
|
+
return installedVersion;
|
|
449
|
+
} catch (error) {
|
|
450
|
+
let recoveryError = null;
|
|
451
|
+
try {
|
|
452
|
+
await installVersion(currentVersion);
|
|
453
|
+
const restoredVersion = await readInstalledVersion();
|
|
454
|
+
if (restoredVersion !== currentVersion) {
|
|
455
|
+
throw new Error(`restored version is ${restoredVersion || "unknown"}`);
|
|
456
|
+
}
|
|
457
|
+
} catch (restoreError) {
|
|
458
|
+
recoveryError = restoreError;
|
|
459
|
+
}
|
|
460
|
+
const suffix = recoveryError
|
|
461
|
+
? ` Automatic rollback could not be verified (${errorMessage(recoveryError)}).`
|
|
462
|
+
: ` SIMY ${currentVersion} was restored.`;
|
|
463
|
+
throw new Error(`${errorMessage(error)}${suffix}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export async function runCliUpdateGate({
|
|
468
|
+
argv = [],
|
|
469
|
+
packageInfo,
|
|
470
|
+
mode = "startup",
|
|
471
|
+
disabled = false,
|
|
472
|
+
allowMajor = false,
|
|
473
|
+
confirmMajor,
|
|
474
|
+
incompatibleRetry = false,
|
|
475
|
+
packageRoot = PACKAGE_ROOT,
|
|
476
|
+
entryPath,
|
|
477
|
+
env = process.env,
|
|
478
|
+
logger = console.log,
|
|
479
|
+
errorLogger = console.error,
|
|
480
|
+
dependencies = {},
|
|
481
|
+
} = {}) {
|
|
482
|
+
const info = packageInfo || (await readCliPackageInfo(packageRoot));
|
|
483
|
+
const installMode = dependencies.installMode || (await detectInstallMode({
|
|
484
|
+
packageRoot,
|
|
485
|
+
execFileCommand: dependencies.execFileCommand,
|
|
486
|
+
}));
|
|
487
|
+
const result = {
|
|
488
|
+
currentVersion: info.version,
|
|
489
|
+
latestVersion: null,
|
|
490
|
+
installMode,
|
|
491
|
+
outcome: disabled ? "disabled" : "not_checked",
|
|
492
|
+
exitCode: null,
|
|
493
|
+
reexecuted: false,
|
|
494
|
+
};
|
|
495
|
+
const report = (outcome, latest = null) => {
|
|
496
|
+
result.outcome = outcome;
|
|
497
|
+
result.latestVersion = latest;
|
|
498
|
+
if (mode !== "startup") {
|
|
499
|
+
logger(`Current version: ${info.version}`);
|
|
500
|
+
logger(`Latest version: ${latest || "unknown"}`);
|
|
501
|
+
logger(`Install mode: ${installMode}`);
|
|
502
|
+
logger(`Result: ${outcome}`);
|
|
503
|
+
}
|
|
504
|
+
return result;
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
if (disabled) return report("disabled");
|
|
508
|
+
if (mode === "startup" && (
|
|
509
|
+
env.SIMY_DAEMON_CHILD === "1" || env[UPDATE_REEXEC_ENV] === info.version
|
|
510
|
+
)) {
|
|
511
|
+
return report("reexec_guard");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
let release;
|
|
515
|
+
try {
|
|
516
|
+
const releaseInfo = await (dependencies.checkLatestVersion || checkLatestVersion)();
|
|
517
|
+
release = String(releaseInfo?.version || "").trim();
|
|
518
|
+
if (!parseStableVersion(release)) throw new Error("The npm registry returned an invalid stable CLI version.");
|
|
519
|
+
} catch (error) {
|
|
520
|
+
if (mode !== "startup") errorLogger(`Update check failed: ${errorMessage(error)}`);
|
|
521
|
+
result.error = errorMessage(error);
|
|
522
|
+
return report("check_failed");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (compareStableVersions(release, info.version) <= 0) return report("up_to_date", release);
|
|
526
|
+
const change = classifyVersionChange(info.version, release);
|
|
527
|
+
const action = updateAction(release);
|
|
528
|
+
result.recoveryCommand = action.command;
|
|
529
|
+
|
|
530
|
+
if (installMode !== "global_npm") {
|
|
531
|
+
logger(`SIMY CLI ${release} is available. This ${installMode} installation is read-only; run: ${action.command}`);
|
|
532
|
+
return report("manual_install_required", release);
|
|
533
|
+
}
|
|
534
|
+
if (change === "major" && !allowMajor) {
|
|
535
|
+
if (mode === "update" && typeof confirmMajor === "function") {
|
|
536
|
+
const confirmed = await confirmMajor({ currentVersion: info.version, latestVersion: release });
|
|
537
|
+
if (!confirmed) {
|
|
538
|
+
logger(`SIMY CLI ${release} was not installed.`);
|
|
539
|
+
return report("major_update_declined", release);
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
logger(`SIMY CLI ${release} is a major update and was not installed. Run \`simy update\` to review and confirm it.`);
|
|
543
|
+
return report("major_confirmation_required", release);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (mode === "check") return report("update_available", release);
|
|
547
|
+
|
|
548
|
+
if (change === "minor") logger(`Updating SIMY CLI ${info.version} → ${release}...`);
|
|
549
|
+
if (change === "major") logger(`Installing confirmed major SIMY CLI update ${info.version} → ${release}...`);
|
|
550
|
+
const stateRoot = dependencies.stateRoot || defaultStateRoot();
|
|
551
|
+
const acquire = dependencies.acquireUpdateLock || (() => acquireGlobalUpdateLock(stateRoot, dependencies));
|
|
552
|
+
let releaseLock = await acquire();
|
|
553
|
+
if (!releaseLock) {
|
|
554
|
+
const installed = await waitForInstalledVersion(release, {
|
|
555
|
+
readInstalledVersion: dependencies.readInstalledVersion || (() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand })),
|
|
556
|
+
timeoutMs: dependencies.lockWaitTimeoutMs,
|
|
557
|
+
pollMs: dependencies.lockPollMs,
|
|
558
|
+
});
|
|
559
|
+
if (!installed) {
|
|
560
|
+
logger(`Another SIMY process holds the update lock. Retry shortly or run: ${action.command}`);
|
|
561
|
+
return report("waiting_for_other_updater", release);
|
|
562
|
+
}
|
|
563
|
+
} else {
|
|
564
|
+
try {
|
|
565
|
+
await installAndVerifyVersion({
|
|
566
|
+
currentVersion: info.version,
|
|
567
|
+
targetVersion: release,
|
|
568
|
+
installVersion: dependencies.installVersion || ((version) => installGlobalNpmVersion(version, { execFileCommand: dependencies.execFileCommand })),
|
|
569
|
+
readInstalledVersion: dependencies.readInstalledVersion || (() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand })),
|
|
570
|
+
});
|
|
571
|
+
} catch (error) {
|
|
572
|
+
errorLogger(`SIMY update failed: ${errorMessage(error)}`);
|
|
573
|
+
errorLogger(`Recovery: npm install -g ${CLI_PACKAGE_NAME}@${info.version}`);
|
|
574
|
+
result.error = errorMessage(error);
|
|
575
|
+
return report("install_failed", release);
|
|
576
|
+
} finally {
|
|
577
|
+
await releaseLock();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (mode === "update" && !entryPath) return report("updated", release);
|
|
582
|
+
if (!entryPath) throw new Error("The CLI entry path is required to restart after an update.");
|
|
583
|
+
const reexecute = dependencies.reexecute || reexecuteCliCommand;
|
|
584
|
+
const childEnv = {
|
|
585
|
+
...env,
|
|
586
|
+
[UPDATE_REEXEC_ENV]: release,
|
|
587
|
+
};
|
|
588
|
+
if (incompatibleRetry) childEnv[INCOMPATIBLE_RETRY_ENV] = "1";
|
|
589
|
+
let exitCode;
|
|
590
|
+
try {
|
|
591
|
+
exitCode = await reexecute({ entryPath, argv, env: childEnv, spawnProcess: dependencies.spawnProcess });
|
|
592
|
+
} catch (error) {
|
|
593
|
+
let rollbackDetail = `Recovery: npm install -g ${CLI_PACKAGE_NAME}@${info.version}`;
|
|
594
|
+
try {
|
|
595
|
+
const installVersion = dependencies.installVersion || ((version) => installGlobalNpmVersion(version, { execFileCommand: dependencies.execFileCommand }));
|
|
596
|
+
const readInstalledVersion = dependencies.readInstalledVersion || (() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand }));
|
|
597
|
+
await installVersion(info.version);
|
|
598
|
+
const restored = await readInstalledVersion();
|
|
599
|
+
if (restored !== info.version) throw new Error(`installed version is ${restored || "unknown"}`);
|
|
600
|
+
rollbackDetail = `SIMY ${info.version} was restored. ${rollbackDetail}`;
|
|
601
|
+
} catch (rollbackError) {
|
|
602
|
+
rollbackDetail = `Automatic rollback failed (${errorMessage(rollbackError)}). ${rollbackDetail}`;
|
|
603
|
+
}
|
|
604
|
+
errorLogger(`Updated SIMY could not start: ${errorMessage(error)}`);
|
|
605
|
+
errorLogger(rollbackDetail);
|
|
606
|
+
result.error = errorMessage(error);
|
|
607
|
+
return report("restart_failed", release);
|
|
608
|
+
}
|
|
609
|
+
result.exitCode = exitCode;
|
|
610
|
+
result.reexecuted = true;
|
|
611
|
+
result.outcome = "updated_and_reexecuted";
|
|
612
|
+
result.latestVersion = release;
|
|
613
|
+
return result;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export async function reexecuteCliCommand({
|
|
617
|
+
entryPath,
|
|
618
|
+
argv,
|
|
619
|
+
env = process.env,
|
|
620
|
+
spawnProcess = spawn,
|
|
621
|
+
} = {}) {
|
|
622
|
+
return new Promise((resolve, reject) => {
|
|
623
|
+
const child = spawnProcess(process.execPath, [entryPath, ...argv], {
|
|
624
|
+
env,
|
|
625
|
+
stdio: "inherit",
|
|
626
|
+
});
|
|
627
|
+
child.once("error", reject);
|
|
628
|
+
child.once("exit", (code, signal) => {
|
|
629
|
+
if (signal) reject(new Error(`Updated SIMY exited from signal ${signal}.`));
|
|
630
|
+
else resolve(code ?? 1);
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
419
635
|
export function isRegistryIdle(registry) {
|
|
420
636
|
const runs = registry?.list?.() || [];
|
|
421
637
|
return runs.every((run) => {
|
|
@@ -450,6 +666,20 @@ export function isPatchUpgrade(currentVersion, targetVersion) {
|
|
|
450
666
|
);
|
|
451
667
|
}
|
|
452
668
|
|
|
669
|
+
export function classifyVersionChange(currentVersion, targetVersion) {
|
|
670
|
+
const current = parseStableVersion(currentVersion);
|
|
671
|
+
const target = parseStableVersion(targetVersion);
|
|
672
|
+
if (!current || !target) throw new Error("A stable semantic version is required.");
|
|
673
|
+
if (compareStableVersions(currentVersion, targetVersion) >= 0) return "none";
|
|
674
|
+
if (current[0] !== target[0]) return "major";
|
|
675
|
+
if (current[1] !== target[1]) return "minor";
|
|
676
|
+
return "patch";
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export function shouldRetryIncompatibleCli(error, env = process.env) {
|
|
680
|
+
return error?.code === "incompatible_cli" && env[INCOMPATIBLE_RETRY_ENV] !== "1";
|
|
681
|
+
}
|
|
682
|
+
|
|
453
683
|
export async function restartDaemonProcess({
|
|
454
684
|
entryPath,
|
|
455
685
|
argv,
|
|
@@ -518,7 +748,7 @@ export async function writeUpdateHandoffFromEnvironment({
|
|
|
518
748
|
|
|
519
749
|
export function autoUpdateHelpText() {
|
|
520
750
|
return [
|
|
521
|
-
"Recommended installation (enables
|
|
751
|
+
"Recommended installation (enables verified CLI auto-update):",
|
|
522
752
|
` npm install -g ${CLI_PACKAGE_NAME}`,
|
|
523
753
|
" simy --daemon",
|
|
524
754
|
].join("\n");
|
|
@@ -543,7 +773,7 @@ function manualUpdateMessage({
|
|
|
543
773
|
return `SIMY CLI ${targetVersion} is available. This version change requires manual confirmation.`;
|
|
544
774
|
}
|
|
545
775
|
|
|
546
|
-
function updateAction(version) {
|
|
776
|
+
export function updateAction(version) {
|
|
547
777
|
return {
|
|
548
778
|
id: "install_global_cli",
|
|
549
779
|
label: "Install and restart SIMY",
|
|
@@ -556,7 +786,7 @@ function parseStableVersion(value) {
|
|
|
556
786
|
return match ? match.slice(1).map(Number) : null;
|
|
557
787
|
}
|
|
558
788
|
|
|
559
|
-
async function
|
|
789
|
+
export async function acquireGlobalUpdateLock(stateRoot = defaultStateRoot(), dependencies = {}) {
|
|
560
790
|
const now = dependencies.now || Date.now;
|
|
561
791
|
const lockDirectory = path.join(stateRoot, "updates");
|
|
562
792
|
const lockPath = path.join(lockDirectory, "global-npm-update.lock");
|
|
@@ -585,6 +815,23 @@ async function acquireUpdateLock(stateRoot, dependencies = {}) {
|
|
|
585
815
|
return null;
|
|
586
816
|
}
|
|
587
817
|
|
|
818
|
+
async function waitForInstalledVersion(targetVersion, {
|
|
819
|
+
readInstalledVersion,
|
|
820
|
+
timeoutMs = 30_000,
|
|
821
|
+
pollMs = 100,
|
|
822
|
+
}) {
|
|
823
|
+
const startedAt = Date.now();
|
|
824
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
825
|
+
try {
|
|
826
|
+
if ((await readInstalledVersion()) === targetVersion) return true;
|
|
827
|
+
} catch {
|
|
828
|
+
// npm may replace the package directory while the lock owner is installing.
|
|
829
|
+
}
|
|
830
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
831
|
+
}
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
|
|
588
835
|
async function isStaleLock(lockPath, now) {
|
|
589
836
|
try {
|
|
590
837
|
const details = await stat(lockPath);
|
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.
|
|
16
|
+
export const CLI_VERSION = "0.5.0";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
package/src/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
5
6
|
|
|
6
7
|
import { startAgent } from "./agent.js";
|
|
7
8
|
import {
|
|
@@ -9,6 +10,8 @@ import {
|
|
|
9
10
|
createCliAutoUpdater,
|
|
10
11
|
readCliPackageInfo,
|
|
11
12
|
restartDaemonProcess,
|
|
13
|
+
runCliUpdateGate,
|
|
14
|
+
shouldRetryIncompatibleCli,
|
|
12
15
|
writeUpdateHandoffFromEnvironment,
|
|
13
16
|
} from "./auto-update.js";
|
|
14
17
|
import { openAuthorizationUrl } from "./browser.js";
|
|
@@ -20,26 +23,75 @@ import { DEFAULT_WEB_ORIGIN, resolveWebOrigin } from "./web-origin.js";
|
|
|
20
23
|
|
|
21
24
|
const argv = process.argv.slice(2);
|
|
22
25
|
const args = new Set(argv);
|
|
26
|
+
const dispatchArgv = argv.filter((value) => value !== "--no-auto-update");
|
|
27
|
+
const entryPath = fileURLToPath(import.meta.url);
|
|
28
|
+
const packageInfo = await readCliPackageInfo();
|
|
29
|
+
const updatesDisabled = args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0";
|
|
23
30
|
|
|
24
|
-
if (
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
if (dispatchArgv[0] === "update") {
|
|
32
|
+
const checkOnly = args.has("--check");
|
|
33
|
+
const result = await runCliUpdateGate({
|
|
34
|
+
argv,
|
|
35
|
+
packageInfo,
|
|
36
|
+
mode: checkOnly ? "check" : "update",
|
|
37
|
+
disabled: updatesDisabled,
|
|
38
|
+
allowMajor: args.has("--yes"),
|
|
39
|
+
confirmMajor: confirmMajorUpdate,
|
|
40
|
+
});
|
|
41
|
+
process.exitCode = ["check_failed", "install_failed"].includes(result.outcome) ? 1 : 0;
|
|
42
|
+
} else {
|
|
43
|
+
const startupUpdate = await runCliUpdateGate({
|
|
44
|
+
argv,
|
|
45
|
+
packageInfo,
|
|
46
|
+
entryPath,
|
|
47
|
+
disabled: updatesDisabled,
|
|
48
|
+
});
|
|
49
|
+
if (startupUpdate.reexecuted) {
|
|
50
|
+
process.exitCode = startupUpdate.exitCode;
|
|
51
|
+
} else {
|
|
52
|
+
await runCommand();
|
|
32
53
|
}
|
|
33
|
-
process.exit();
|
|
34
54
|
}
|
|
35
55
|
|
|
36
|
-
|
|
37
|
-
|
|
56
|
+
async function runCommand() {
|
|
57
|
+
if (dispatchArgv[0] === "sqm") {
|
|
58
|
+
try {
|
|
59
|
+
const { runSqmCommand } = await import("./sqm/command.js");
|
|
60
|
+
process.exitCode = await runSqmCommand(dispatchArgv.slice(1), { cliVersion: packageInfo.version });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (shouldRetryIncompatibleCli(error)) {
|
|
63
|
+
const retried = await runCliUpdateGate({
|
|
64
|
+
argv,
|
|
65
|
+
packageInfo,
|
|
66
|
+
entryPath,
|
|
67
|
+
mode: "incompatible",
|
|
68
|
+
disabled: updatesDisabled,
|
|
69
|
+
allowMajor: args.has("--yes"),
|
|
70
|
+
confirmMajor: confirmMajorUpdate,
|
|
71
|
+
incompatibleRetry: true,
|
|
72
|
+
});
|
|
73
|
+
if (retried.reexecuted) {
|
|
74
|
+
process.exitCode = retried.exitCode;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const required = error.minimumVersion ? ` (requires ${error.minimumVersion}+)` : "";
|
|
78
|
+
console.error(`SQM requires a newer SIMY CLI${required}, but automatic recovery did not install one.`);
|
|
79
|
+
}
|
|
80
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
81
|
+
process.exitCode = 2;
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (args.has("--help") || args.has("-h")) {
|
|
87
|
+
console.log(`Usage:
|
|
38
88
|
simy Start the local SIMY agent
|
|
39
89
|
simy --daemon Start it in the background
|
|
40
90
|
simy --no-tui Start the foreground HTTP agent without the interactive console
|
|
41
91
|
simy sqm min-check Run the fast deterministic SQM diff preflight
|
|
42
92
|
simy sqm check Run full scenarios, write proof.json, and sign the run
|
|
93
|
+
simy update --check Check the installed and latest stable CLI versions
|
|
94
|
+
simy update Install an eligible stable CLI update
|
|
43
95
|
|
|
44
96
|
Options:
|
|
45
97
|
--daemon, --deamon Run detached in the background
|
|
@@ -47,73 +99,92 @@ Options:
|
|
|
47
99
|
--no-open Do not open the browser when authorization is required
|
|
48
100
|
--port <port> Bind a specific localhost port
|
|
49
101
|
--host <url> Connect to a SIMY Web origin (default: ${DEFAULT_WEB_ORIGIN})
|
|
50
|
-
--no-auto-update Disable
|
|
102
|
+
--no-auto-update Disable update checks and automatic installation
|
|
51
103
|
|
|
52
104
|
${autoUpdateHelpText()}
|
|
53
105
|
`);
|
|
54
|
-
|
|
55
|
-
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
56
108
|
|
|
57
|
-
|
|
58
|
-
const interactive = !daemon && !args.has("--no-tui") && process.stdin.isTTY && process.stdout.isTTY;
|
|
59
|
-
const port = readPort(argv);
|
|
60
|
-
const packageInfo = await readCliPackageInfo();
|
|
61
|
-
let webOrigin;
|
|
62
|
-
try {
|
|
63
|
-
webOrigin = resolveWebOrigin(readOption(argv, "--host"));
|
|
64
|
-
} catch (error) {
|
|
65
|
-
console.error(error instanceof Error ? error.message : String(error));
|
|
66
|
-
process.exit(2);
|
|
109
|
+
await runLocalAgent();
|
|
67
110
|
}
|
|
68
111
|
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
112
|
+
async function runLocalAgent() {
|
|
113
|
+
const daemon = args.has("--daemon") || args.has("--deamon");
|
|
114
|
+
const interactive = !daemon && !args.has("--no-tui") && process.stdin.isTTY && process.stdout.isTTY;
|
|
115
|
+
const port = readPort(argv);
|
|
116
|
+
let webOrigin;
|
|
117
|
+
try {
|
|
118
|
+
webOrigin = resolveWebOrigin(readOption(argv, "--host"));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
121
|
+
process.exitCode = 2;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
79
124
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
dependencies: { packageInfo },
|
|
86
|
-
});
|
|
87
|
-
const agent = await startAgent({
|
|
88
|
-
requestedPort: port,
|
|
89
|
-
daemon,
|
|
90
|
-
webOrigin,
|
|
91
|
-
quiet: interactive,
|
|
92
|
-
updateManager,
|
|
93
|
-
});
|
|
94
|
-
const shutdownAgent = createAgentShutdown(agent);
|
|
95
|
-
installAgentShutdownSignalHandlers({ shutdown: shutdownAgent });
|
|
96
|
-
await writeUpdateHandoffFromEnvironment({ version: packageInfo.version, port: agent.port });
|
|
97
|
-
updateManager.start({
|
|
98
|
-
registry: agent.registry,
|
|
99
|
-
restartDaemon: async (targetVersion) => {
|
|
100
|
-
await restartDaemonProcess({
|
|
101
|
-
entryPath: fileURLToPath(import.meta.url),
|
|
102
|
-
argv: process.argv.slice(2),
|
|
103
|
-
targetVersion,
|
|
125
|
+
if (daemon && process.env.SIMY_DAEMON_CHILD !== "1") {
|
|
126
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
|
|
127
|
+
detached: true,
|
|
128
|
+
stdio: "ignore",
|
|
129
|
+
env: { ...process.env, SIMY_DAEMON_CHILD: "1" },
|
|
104
130
|
});
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
131
|
+
child.unref();
|
|
132
|
+
console.log("SIMY local agent started in the background.");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const updateManager = await createCliAutoUpdater({
|
|
137
|
+
daemon,
|
|
138
|
+
interactive,
|
|
139
|
+
requestedPort: port,
|
|
140
|
+
disabled: args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0",
|
|
141
|
+
dependencies: { packageInfo },
|
|
142
|
+
});
|
|
143
|
+
const agent = await startAgent({
|
|
144
|
+
requestedPort: port,
|
|
145
|
+
daemon,
|
|
146
|
+
webOrigin,
|
|
147
|
+
quiet: interactive,
|
|
148
|
+
updateManager,
|
|
149
|
+
});
|
|
150
|
+
const shutdownAgent = createAgentShutdown(agent);
|
|
151
|
+
installAgentShutdownSignalHandlers({ shutdown: shutdownAgent });
|
|
152
|
+
await writeUpdateHandoffFromEnvironment({ version: packageInfo.version, port: agent.port });
|
|
153
|
+
updateManager.start({
|
|
154
|
+
registry: agent.registry,
|
|
155
|
+
restartDaemon: async (targetVersion) => {
|
|
156
|
+
await restartDaemonProcess({
|
|
157
|
+
entryPath: fileURLToPath(import.meta.url),
|
|
158
|
+
argv: process.argv.slice(2),
|
|
159
|
+
targetVersion,
|
|
160
|
+
});
|
|
161
|
+
await shutdownAgent();
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
if (agent.loginUrl && !args.has("--no-open")) {
|
|
165
|
+
await openAuthorizationUrl(agent.loginUrl);
|
|
166
|
+
}
|
|
167
|
+
if (interactive) {
|
|
168
|
+
const { runAgenticLoopConsole } = await import("./console/index.js");
|
|
169
|
+
try {
|
|
170
|
+
await runAgenticLoopConsole(agent);
|
|
171
|
+
} finally {
|
|
172
|
+
await shutdownAgent();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
110
175
|
}
|
|
111
|
-
|
|
112
|
-
|
|
176
|
+
|
|
177
|
+
async function confirmMajorUpdate({ currentVersion, latestVersion }) {
|
|
178
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
179
|
+
console.log(`Major update ${currentVersion} → ${latestVersion} requires confirmation. Re-run with \`simy update --yes\`.`);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
113
183
|
try {
|
|
114
|
-
await
|
|
184
|
+
const answer = await prompt.question(`Install major SIMY CLI update ${currentVersion} → ${latestVersion}? [y/N] `);
|
|
185
|
+
return /^(y|yes)$/i.test(answer.trim());
|
|
115
186
|
} finally {
|
|
116
|
-
|
|
187
|
+
prompt.close();
|
|
117
188
|
}
|
|
118
189
|
}
|
|
119
190
|
|
|
@@ -79,8 +79,14 @@ export function completionEvidenceChecks(charter, attempt) {
|
|
|
79
79
|
if (contract.version !== COMPLETION_CONTRACT_VERSION) return [];
|
|
80
80
|
|
|
81
81
|
const uncertainty = normalizeUncertaintyVerification(attempt?.uncertainty_verification);
|
|
82
|
-
const criterionEvidence = normalizeCriterionEvidence(attempt?.criterion_evidence);
|
|
83
82
|
const rows = acceptanceCriterionRows(charter);
|
|
83
|
+
const criterionEvidence = normalizeCriterionEvidence(attempt?.criterion_evidence);
|
|
84
|
+
const expectedCriterionIds = new Set(rows.map((row) => row.id));
|
|
85
|
+
const unexpectedCriterionIds = uniqueStrings(
|
|
86
|
+
criterionEvidence
|
|
87
|
+
.map((item) => item.criterion_id)
|
|
88
|
+
.filter((criterionId) => !expectedCriterionIds.has(criterionId)),
|
|
89
|
+
);
|
|
84
90
|
const checks = [
|
|
85
91
|
check(
|
|
86
92
|
"top_uncertainty_recorded",
|
|
@@ -110,6 +116,14 @@ export function completionEvidenceChecks(charter, attempt) {
|
|
|
110
116
|
? "The executor marked the highest uncertainty resolved with observed evidence."
|
|
111
117
|
: uncertainty.fallback || "The highest uncertainty remains unresolved.",
|
|
112
118
|
),
|
|
119
|
+
check(
|
|
120
|
+
"criterion_evidence_scope",
|
|
121
|
+
"Criterion evidence matches the charter acceptance criteria",
|
|
122
|
+
unexpectedCriterionIds.length === 0,
|
|
123
|
+
unexpectedCriterionIds.length === 0
|
|
124
|
+
? "Every criterion evidence row references a chartered acceptance criterion."
|
|
125
|
+
: `Unexpected criterion evidence: ${unexpectedCriterionIds.join(", ")}.`,
|
|
126
|
+
),
|
|
113
127
|
];
|
|
114
128
|
|
|
115
129
|
for (const row of rows) {
|
package/src/sqm/bundle-store.js
CHANGED
|
@@ -9,6 +9,18 @@ import { validateKnowledgeBundle } from "./validation.js";
|
|
|
9
9
|
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
10
10
|
export const SQM_BUNDLE_FETCH_TIMEOUT_MS = 15_000;
|
|
11
11
|
|
|
12
|
+
export class SqmIncompatibleCliError extends Error {
|
|
13
|
+
constructor(message, { currentVersion, minimumVersion, modules = [] } = {}) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "SqmIncompatibleCliError";
|
|
16
|
+
this.code = "incompatible_cli";
|
|
17
|
+
this.currentVersion = currentVersion || null;
|
|
18
|
+
this.minimumVersion = minimumVersion || null;
|
|
19
|
+
this.modules = modules;
|
|
20
|
+
this.fallbackAllowed = false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
export async function loadKnowledgeBundle({
|
|
13
25
|
repository,
|
|
14
26
|
cliVersion,
|
|
@@ -68,6 +80,7 @@ export async function loadKnowledgeBundle({
|
|
|
68
80
|
});
|
|
69
81
|
return result(bundle, [], "cloud");
|
|
70
82
|
} catch (error) {
|
|
83
|
+
if (error?.code === "incompatible_cli") throw error;
|
|
71
84
|
if (error?.fallbackAllowed !== false && cached && Date.parse(cached.bundle.expires_at) > now) return result(cached.bundle, [`SQM Cloud unavailable; using last verified bundle: ${message(error)}`], "stale_cache");
|
|
72
85
|
return result(null, [`SQM knowledge could not be used; shadow checks were skipped: ${message(error)}`], "unavailable");
|
|
73
86
|
}
|
|
@@ -169,6 +182,18 @@ async function fetchBundle({ endpoint, repository, cliVersion, token, organizati
|
|
|
169
182
|
}
|
|
170
183
|
if (!response.ok) {
|
|
171
184
|
const body = await response.text().catch(() => "");
|
|
185
|
+
const parsed = parseErrorBody(body);
|
|
186
|
+
if (response.status === 409 && parsed?.error?.code === "incompatible_cli") {
|
|
187
|
+
const modules = Array.isArray(parsed.error.modules) ? parsed.error.modules : [];
|
|
188
|
+
const minimumVersion = modules
|
|
189
|
+
.map((module) => String(module?.min_cli_version || ""))
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.sort(compareSemverDescending)[0] || null;
|
|
192
|
+
throw new SqmIncompatibleCliError(
|
|
193
|
+
parsed.error.message || "The selected knowledge bundle requires a newer SIMY CLI.",
|
|
194
|
+
{ currentVersion: parsed.error.cli_version || cliVersion, minimumVersion, modules },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
172
197
|
const error = new Error(`SQM Cloud returned HTTP ${response.status}${body ? `: ${body.slice(0, 300)}` : ""}.`);
|
|
173
198
|
error.fallbackAllowed = response.status >= 500 && response.status <= 599;
|
|
174
199
|
throw error;
|
|
@@ -186,6 +211,23 @@ async function fetchBundle({ endpoint, repository, cliVersion, token, organizati
|
|
|
186
211
|
}
|
|
187
212
|
}
|
|
188
213
|
|
|
214
|
+
function parseErrorBody(body) {
|
|
215
|
+
try {
|
|
216
|
+
return JSON.parse(body);
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function compareSemverDescending(left, right) {
|
|
223
|
+
const a = left.split(".").map(Number);
|
|
224
|
+
const b = right.split(".").map(Number);
|
|
225
|
+
for (let index = 0; index < 3; index += 1) {
|
|
226
|
+
if ((a[index] || 0) !== (b[index] || 0)) return (b[index] || 0) - (a[index] || 0);
|
|
227
|
+
}
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
189
231
|
async function readVerifiedCache(location, options, identity) {
|
|
190
232
|
try {
|
|
191
233
|
const [bundleRaw, metadataRaw] = await Promise.all([readFile(location.bundle, "utf8"), readFile(location.metadata, "utf8")]);
|