@north-light/crouter 0.3.281 → 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 (32) 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/command-manifests/registry.d.ts +3 -1
  11. package/dist/core/command-manifests/schema.d.ts +2 -75
  12. package/dist/core/command-manifests/schema.js +5 -0
  13. package/dist/core/command-plugins/compose.js +1 -1
  14. package/dist/core/command-plugins/revalidate.d.ts +18 -0
  15. package/dist/core/command-plugins/revalidate.js +152 -0
  16. package/dist/core/command-plugins/transport/http-fetch.d.ts +29 -4
  17. package/dist/core/command-plugins/transport/http-fetch.js +18 -8
  18. package/dist/core/command-plugins/transport/http-invoke.js +7 -0
  19. package/dist/core/command.d.ts +8 -0
  20. package/dist/core/command.js +19 -3
  21. package/dist/core/exclusive-lock.d.ts +12 -0
  22. package/dist/core/exclusive-lock.js +29 -0
  23. package/dist/core/installed-plugins.js +31 -0
  24. package/dist/core/manifest-recovery.d.ts +15 -0
  25. package/dist/core/manifest-recovery.js +72 -0
  26. package/dist/core/manifest-stale.d.ts +18 -0
  27. package/dist/core/manifest-stale.js +39 -0
  28. package/dist/core/plugin-swap-lock.d.ts +9 -0
  29. package/dist/core/plugin-swap-lock.js +31 -0
  30. package/dist/types.d.ts +7 -0
  31. package/package.json +2 -1
  32. package/runtime.lock.json +2 -2
@@ -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
+ }
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.281",
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.281",
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.281",
9
+ "version": "0.3.282",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {