@north-light/crouter 0.3.280 → 0.3.282

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.
Files changed (36) hide show
  1. package/dist/api/plugin-manifest-schema.d.ts +208 -0
  2. package/dist/api/plugin-manifest-schema.js +23 -0
  3. package/dist/cli.js +8 -2
  4. package/dist/clients/attach/viewer.js +533 -533
  5. package/dist/commands/pkg/browse/catalog.js +1 -1
  6. package/dist/commands/pkg/plugin-manage.d.ts +46 -1
  7. package/dist/commands/pkg/plugin-manage.js +170 -20
  8. package/dist/core/__tests__/integration/plugin-revalidate.test.d.ts +1 -0
  9. package/dist/core/__tests__/integration/plugin-revalidate.test.js +355 -0
  10. package/dist/core/__tests__/model-pin-durability.test.js +31 -3
  11. package/dist/core/command-manifests/registry.d.ts +3 -1
  12. package/dist/core/command-manifests/schema.d.ts +2 -75
  13. package/dist/core/command-manifests/schema.js +5 -0
  14. package/dist/core/command-plugins/compose.js +1 -1
  15. package/dist/core/command-plugins/revalidate.d.ts +18 -0
  16. package/dist/core/command-plugins/revalidate.js +152 -0
  17. package/dist/core/command-plugins/transport/http-fetch.d.ts +29 -4
  18. package/dist/core/command-plugins/transport/http-fetch.js +18 -8
  19. package/dist/core/command-plugins/transport/http-invoke.js +7 -0
  20. package/dist/core/command.d.ts +8 -0
  21. package/dist/core/command.js +19 -3
  22. package/dist/core/exclusive-lock.d.ts +12 -0
  23. package/dist/core/exclusive-lock.js +29 -0
  24. package/dist/core/installed-plugins.js +31 -0
  25. package/dist/core/manifest-recovery.d.ts +15 -0
  26. package/dist/core/manifest-recovery.js +72 -0
  27. package/dist/core/manifest-stale.d.ts +18 -0
  28. package/dist/core/manifest-stale.js +39 -0
  29. package/dist/core/plugin-swap-lock.d.ts +9 -0
  30. package/dist/core/plugin-swap-lock.js +31 -0
  31. package/dist/core/runtime/launch.d.ts +7 -2
  32. package/dist/core/runtime/launch.js +2 -1
  33. package/dist/daemon/api/__tests__/node-model-validation.test.js +15 -7
  34. package/dist/types.d.ts +7 -0
  35. package/package.json +2 -1
  36. package/runtime.lock.json +2 -2
@@ -0,0 +1,15 @@
1
+ import type { RootDef } from './command.js';
2
+ /**
3
+ * Run `argv` against `root`, and if the backend answers `manifest_stale`,
4
+ * refetch that plugin's package and run the ORIGINAL argv again against a
5
+ * freshly resolved tree — exactly once.
6
+ *
7
+ * Re-parsing the original argv, rather than re-running the leaf that failed, is
8
+ * what makes a RENAMED verb recoverable: the old leaf no longer exists in the
9
+ * refreshed tree, and only the dispatcher can answer that against the fresh
10
+ * tree's own siblings.
11
+ *
12
+ * Never throws: every outcome is rendered through the dispatcher's own
13
+ * `handle`, so a caller can mark the end of dispatch unconditionally.
14
+ */
15
+ export declare function dispatchWithStaleRecovery(root: RootDef, argv: string[]): Promise<void>;
@@ -0,0 +1,72 @@
1
+ // Dispatch with recovery from an out-of-date command description.
2
+ //
3
+ // Extracted from the cli entry so the whole behaviour — rethrow, refetch,
4
+ // re-parse, exactly-once — can be driven by a test through `runCli` and a real
5
+ // error off the wire, rather than only by launching the binary.
6
+ import { resolveRoot } from '../build-root.js';
7
+ import { runCli } from './command.js';
8
+ import { forceRevalidateBundlePlugin } from './command-plugins/revalidate.js';
9
+ import { handle } from './io.js';
10
+ import { staleManifestPlugin } from './manifest-stale.js';
11
+ /**
12
+ * Run `argv` against `root`, and if the backend answers `manifest_stale`,
13
+ * refetch that plugin's package and run the ORIGINAL argv again against a
14
+ * freshly resolved tree — exactly once.
15
+ *
16
+ * Re-parsing the original argv, rather than re-running the leaf that failed, is
17
+ * what makes a RENAMED verb recoverable: the old leaf no longer exists in the
18
+ * refreshed tree, and only the dispatcher can answer that against the fresh
19
+ * tree's own siblings.
20
+ *
21
+ * Never throws: every outcome is rendered through the dispatcher's own
22
+ * `handle`, so a caller can mark the end of dispatch unconditionally.
23
+ */
24
+ export async function dispatchWithStaleRecovery(root, argv) {
25
+ try {
26
+ // The dispatcher renders every other failure itself; a `manifest_stale`
27
+ // error is the one it rethrows, because recovery needs this frame.
28
+ await runCli(root, argv);
29
+ return;
30
+ }
31
+ catch (error) {
32
+ if (!(await refetchStalePlugin(error))) {
33
+ // Nothing newer to run against: report the backend's own complaint
34
+ // exactly as the dispatcher would have.
35
+ handle(error);
36
+ return;
37
+ }
38
+ try {
39
+ // The refreshed tree, re-resolved: the package this invocation dispatches
40
+ // against is not the one it was built from. Replaying the argv is only
41
+ // safe because a server that sets the flag has promised it — the two
42
+ // preconditions are recorded on `ManifestStaleEnvelope` in
43
+ // `src/api/plugin-manifest-schema.ts`, and crtr cannot check either one.
44
+ await runCli(await resolveRoot(argv[2]), argv);
45
+ }
46
+ catch (retryError) {
47
+ // Exactly once. A second `manifest_stale` against a package just
48
+ // refetched is the server's answer, not another refresh cycle.
49
+ handle(retryError);
50
+ }
51
+ }
52
+ }
53
+ /**
54
+ * A backend that answers `manifest_stale` is saying this client invoked from an
55
+ * out-of-date description of its commands. Refetch that plugin's package and
56
+ * report whether it actually changed. An unchanged package means the operation
57
+ * is genuinely gone, so the server's own error, which already names what it
58
+ * does serve, is the honest answer and stands.
59
+ */
60
+ async function refetchStalePlugin(error) {
61
+ const plugin = staleManifestPlugin(error);
62
+ if (plugin === undefined)
63
+ return false;
64
+ try {
65
+ return await forceRevalidateBundlePlugin(plugin);
66
+ }
67
+ catch {
68
+ // The refetch is the recovery. If it fails there is nothing newer to retry
69
+ // against, and the server's original complaint is what the caller needs.
70
+ return false;
71
+ }
72
+ }
@@ -0,0 +1,18 @@
1
+ import type { ManifestStaleEnvelope } from '../api/plugin-manifest-schema.js';
2
+ /** What crtr attaches to the `CrtrError` it raises for a stale answer. */
3
+ type StaleErrorDetails = Required<ManifestStaleEnvelope> & {
4
+ plugin: string;
5
+ };
6
+ /** Did the backend's own error envelope claim staleness? Reads the parsed JSON
7
+ * body, before it becomes a `CrtrError`. */
8
+ export declare function envelopeClaimsStale(envelope: Record<string, unknown>): boolean;
9
+ /** The details a transport adds when it converts a stale envelope into the
10
+ * error the dispatcher rethrows. */
11
+ export declare function staleErrorDetails(plugin: string): StaleErrorDetails;
12
+ /** Is this the one error class the dispatcher rethrows instead of rendering? */
13
+ export declare function isManifestStaleError(error: unknown): boolean;
14
+ /** The plugin to refetch, or `undefined` when this error is not a recoverable
15
+ * stale answer. A stale flag with no plugin name is not actionable: nothing
16
+ * identifies the package whose description went out of date. */
17
+ export declare function staleManifestPlugin(error: unknown): string | undefined;
18
+ export {};
@@ -0,0 +1,39 @@
1
+ // The one place in crtr that names the `manifest_stale` wire field.
2
+ //
3
+ // The field itself is declared in the published format subpath
4
+ // (`src/api/plugin-manifest-schema.ts`), which is what a plugin backend compiles
5
+ // against. This module binds crtr's readers and writer to that declaration, so
6
+ // renaming the field there fails the build here rather than silently splitting
7
+ // the two sides: a backend that keeps setting a field nobody reads, and a client
8
+ // that never recovers.
9
+ import { CrtrError } from './errors.js';
10
+ /** Bound to the published declaration: `keyof` is the field's literal type, so
11
+ * a rename over there makes this assignment a type error. */
12
+ const STALE_FIELD = 'manifest_stale';
13
+ /** The plugin whose package produced the out-of-date description, carried
14
+ * alongside the flag so the recovery frame knows what to refetch. The backend
15
+ * does not send this — the transport that made the call names it. */
16
+ const PLUGIN_FIELD = 'plugin';
17
+ /** Did the backend's own error envelope claim staleness? Reads the parsed JSON
18
+ * body, before it becomes a `CrtrError`. */
19
+ export function envelopeClaimsStale(envelope) {
20
+ return envelope[STALE_FIELD] === true;
21
+ }
22
+ /** The details a transport adds when it converts a stale envelope into the
23
+ * error the dispatcher rethrows. */
24
+ export function staleErrorDetails(plugin) {
25
+ return { [STALE_FIELD]: true, [PLUGIN_FIELD]: plugin };
26
+ }
27
+ /** Is this the one error class the dispatcher rethrows instead of rendering? */
28
+ export function isManifestStaleError(error) {
29
+ return error instanceof CrtrError && error.details?.[STALE_FIELD] === true;
30
+ }
31
+ /** The plugin to refetch, or `undefined` when this error is not a recoverable
32
+ * stale answer. A stale flag with no plugin name is not actionable: nothing
33
+ * identifies the package whose description went out of date. */
34
+ export function staleManifestPlugin(error) {
35
+ if (!isManifestStaleError(error))
36
+ return undefined;
37
+ const plugin = error.details?.[PLUGIN_FIELD];
38
+ return typeof plugin === 'string' ? plugin : undefined;
39
+ }
@@ -0,0 +1,9 @@
1
+ /** Under the scope root's `tmp/`, alongside the staging and `.prev` dirs the
2
+ * swap itself uses, so one directory holds the whole transition. */
3
+ export declare function bundleSwapLockPath(scopeRootPath: string, name: string): string;
4
+ /** Whether ANOTHER live process currently holds this plugin's swap lock. Our
5
+ * own lock does not count: the swapper reads the plugin list from inside its
6
+ * own critical section. */
7
+ export declare function bundleSwapInFlightElsewhere(scopeRootPath: string, name: string): boolean;
8
+ /** Wait out another process's swap of this plugin, bounded. */
9
+ export declare function awaitBundleSwap(scopeRootPath: string, name: string): void;
@@ -0,0 +1,31 @@
1
+ // The one name for the lock a bundle-plugin package swap is held under, and the
2
+ // two questions a reader asks about it.
3
+ //
4
+ // Two modules need it and neither may own it: the swapper lives in
5
+ // `commands/pkg/plugin-manage.ts` (the whole command graph), the reader in
6
+ // `core/installed-plugins.ts` (deliberately leaf-safe: types, fs-utils,
7
+ // manifest). This module keeps both dependency-light — `node:path` plus the
8
+ // generic lock primitive, which itself imports only `node:fs` and
9
+ // `node:crypto`.
10
+ import { join } from 'node:path';
11
+ import { awaitExclusiveLockRelease, exclusiveLockOwnerPid } from './exclusive-lock.js';
12
+ /** How long a reader waits for another process's swap to finish before it
13
+ * reports what it can see. A swap is one rename of an already-staged, already-
14
+ * validated tree, so a wait this long means the holder is wedged, not slow. */
15
+ const READER_WAIT_MS = 2_000;
16
+ /** Under the scope root's `tmp/`, alongside the staging and `.prev` dirs the
17
+ * swap itself uses, so one directory holds the whole transition. */
18
+ export function bundleSwapLockPath(scopeRootPath, name) {
19
+ return join(scopeRootPath, 'tmp', `${name}.swap.lock`);
20
+ }
21
+ /** Whether ANOTHER live process currently holds this plugin's swap lock. Our
22
+ * own lock does not count: the swapper reads the plugin list from inside its
23
+ * own critical section. */
24
+ export function bundleSwapInFlightElsewhere(scopeRootPath, name) {
25
+ const pid = exclusiveLockOwnerPid(bundleSwapLockPath(scopeRootPath, name));
26
+ return pid !== null && pid !== process.pid;
27
+ }
28
+ /** Wait out another process's swap of this plugin, bounded. */
29
+ export function awaitBundleSwap(scopeRootPath, name) {
30
+ awaitExclusiveLockRelease(bundleSwapLockPath(scopeRootPath, name), READER_WAIT_MS);
31
+ }
@@ -68,8 +68,13 @@ export interface LaunchBuildOptions {
68
68
  /** A newly supplied `--model` override. Resolve it through the target
69
69
  * ladder and any quality floor once, then freeze the resulting concrete
70
70
  * provider/id[:thinking] spec into the launch recipe so every launch,
71
- * revive, and retry consults exactly that model. Existing recipes instead
72
- * carry `modelExact`/`modelIntent` below verbatim. */
71
+ * revive, and retry consults exactly that model — but ONLY when the raw
72
+ * token names a concrete model. A PORTABLE ladder token (`medium`,
73
+ * `anthropic/opus`) is a strength preference, not a model identity, so it
74
+ * keeps its route intent and stays eligible for fallback; freezing it
75
+ * would fail a node closed on a canvas whose only authenticated route is a
76
+ * different provider. Existing recipes instead carry
77
+ * `modelExact`/`modelIntent` below verbatim. */
73
78
  modelOverride?: boolean;
74
79
  /** The TARGET node's cwd — anchors the project-scope stack for kind/ladder
75
80
  * resolution. Defaults to this process's own cwd, which is correct only
@@ -251,7 +251,8 @@ function buildLaunchSpecFromMerged(kind, mode, opts, merged) {
251
251
  const ladders = merged.modelLadders;
252
252
  const kindModel = mode === 'orchestrator' ? kindConfig?.orchestratorModel ?? kindConfig?.model : kindConfig?.model;
253
253
  const chosenModel = opts.modelExact === true ? opts.model : floorReviewModel(kind, opts.model, kindModel, ladders);
254
- const exact = opts.modelExact === true || opts.modelOverride === true;
254
+ const exact = opts.modelExact === true
255
+ || (opts.modelOverride === true && opts.model !== undefined && !isPortableModelToken(opts.model));
255
256
  const resolvedModel = opts.modelExact === true
256
257
  ? chosenModel
257
258
  : (chosenModel !== undefined ? normalizeModel(chosenModel, ladders) : undefined);
@@ -103,7 +103,12 @@ test('promote and yield reject unregistered model recipes before reshaping the n
103
103
  assert.equal(after?.mode, before?.mode, `${operation} did not reshape the node`);
104
104
  }
105
105
  });
106
- test('promote and yield reject an unregistered resolved portable override before reshaping the node', async () => {
106
+ // A portable override names a strength, not a model identity, so it is a route
107
+ // request: the registry cannot pre-judge it, and the broker resolves it against
108
+ // whichever route is authenticated at launch. Gating it on the ladder cell being
109
+ // registered is what killed every `--model medium` child on a canvas whose only
110
+ // authenticated route belonged to another provider.
111
+ test('promote and yield accept a portable override whose ladder cell is unregistered', async () => {
107
112
  const cwd = join(home, 'target-project');
108
113
  mkdirSync(join(cwd, '.crouter'), { recursive: true });
109
114
  writeFileSync(join(cwd, '.crouter', 'config.json'), JSON.stringify({
@@ -113,14 +118,15 @@ test('promote and yield reject an unregistered resolved portable override before
113
118
  for (const operation of ['promote', 'yield']) {
114
119
  const id = `${operation}-portable-model`;
115
120
  createNode(node(id, cwd));
116
- const before = getNode(id);
117
121
  const request = ctx('POST', `/v1/nodes/${id}/${operation}`, id, { model: 'openai/strong' });
118
- await assert.rejects(() => operation === 'promote'
119
- ? handlePromote(request, { modelRegistry: registry })
120
- : handleYield(request, { modelRegistry: registry }), (error) => error instanceof CrtrError && error.code === 'usage' && /target-openai\/missing-strong/.test(error.message));
122
+ if (operation === 'promote')
123
+ await handlePromote(request, { modelRegistry: registry });
124
+ else
125
+ await handleYield(request, { modelRegistry: registry });
121
126
  const after = getNode(id);
122
- assert.deepEqual(after?.launch, before?.launch, `${operation} did not rewrite the launch recipe`);
123
- assert.equal(after?.mode, before?.mode, `${operation} did not reshape the node`);
127
+ assert.equal(after?.launch?.model, 'target-openai/missing-strong', `${operation} resolves the tier against the target scope`);
128
+ assert.equal(after?.launch?.modelExact, undefined, `${operation} leaves the strength request routable`);
129
+ assert.deepEqual(after?.launch?.modelIntent, { family: 'openai', strength: 'strong' }, `${operation} keeps the route intent`);
124
130
  }
125
131
  });
126
132
  test('config normalizes portable models against the target node scope', async () => {
@@ -151,5 +157,7 @@ test('config normalizes portable models against the target node scope', async ()
151
157
  await handleConfig(ctx('PATCH', `/v1/nodes/${id}/config`, id, { model: 'openai/strong' }), { modelRegistry: registry });
152
158
  assert.equal(getNode(id)?.model_override, 'target-openai/o-strong');
153
159
  assert.equal(getNode(id)?.launch?.model, 'target-openai/o-strong');
160
+ // `config --model` records exactness in the model-swap path (updateModelRecipe),
161
+ // not in buildLaunchSpec, so a live user's pick freezes whatever its shape.
154
162
  assert.equal(getNode(id)?.launch?.modelExact, true, 'a config override cannot route away on revive');
155
163
  });
package/dist/types.d.ts CHANGED
@@ -386,8 +386,15 @@ export interface ScopeState {
386
386
  marketplaces: Record<string, {
387
387
  last_updated?: string;
388
388
  }>;
389
+ /** Per-plugin cache bookkeeping for the bundle revalidation pass.
390
+ * `etag` is the validator the server returned with the archive currently
391
+ * unpacked on disk, replaced only on a 200. `checked_at` is written on every
392
+ * revalidation attempt, success or failure, so an unreachable server costs
393
+ * one timed-out probe per TTL rather than one per command. */
389
394
  plugins: Record<string, {
390
395
  last_updated?: string;
396
+ etag?: string;
397
+ checked_at?: string;
391
398
  }>;
392
399
  last_self_check?: string;
393
400
  /** The name of the remote canvas target `crtr canvas use` last selected
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.280",
3
+ "version": "0.3.282",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -76,6 +76,7 @@
76
76
  "typecheck:pi-packages": "npx tsc -p src/builtin-pi-packages/pi-crtr-extensions/tsconfig.json",
77
77
  "test:build-output": "node scripts/build-concurrency-test.mjs",
78
78
  "check:cli-no-canvas": "node scripts/check-cli-no-canvas.mjs",
79
+ "check:cli-cold-start": "node scripts/check-cli-cold-start.mjs",
79
80
  "check:daemon-no-barrel": "node scripts/check-daemon-no-barrel.mjs",
80
81
  "check:quote-style": "node scripts/check-quote-style.mjs",
81
82
  "postinstall": "node scripts/postinstall.mjs",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.280",
3
+ "version": "0.3.282",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.280",
9
+ "version": "0.3.282",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {