@north-light/crouter 0.3.303 → 0.3.305
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/dist/api/__tests__/integration/client.test.js +37 -36
- package/dist/api/client.d.ts +32 -55
- package/dist/api/client.js +102 -98
- package/dist/api/dto/messages.d.ts +6 -5
- package/dist/api/errors.d.ts +3 -0
- package/dist/api/errors.js +10 -0
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/builtin-memory/internal/plugins.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +87 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +31 -26
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +33 -26
- package/dist/clients/attach/viewer.js +584 -584
- package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
- package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
- package/dist/commands/api-client.d.ts +3 -3
- package/dist/commands/api-client.js +3 -3
- package/dist/commands/memory/read.js +2 -1
- package/dist/commands/node/bash.js +6 -6
- package/dist/commands/node/create.js +7 -1
- package/dist/commands/node/message.js +3 -3
- package/dist/commands/node/wait.js +1 -1
- package/dist/commands/node-worktree.js +5 -1
- package/dist/commands/sys/daemon.js +11 -13
- package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
- package/dist/core/__tests__/daemon-boot.test.js +1 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +14 -15
- package/dist/core/__tests__/helpers/harness.d.ts +2 -0
- package/dist/core/__tests__/helpers/harness.js +9 -0
- package/dist/core/__tests__/integration/tmux-surface.test.js +53 -0
- package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
- package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
- package/dist/core/__tests__/kickoff.test.js +40 -0
- package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +25 -0
- package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
- package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
- package/dist/core/__tests__/seam/deadline-wake-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/seam/deadline-wake-delivery.test.js +138 -0
- package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.d.ts +1 -0
- package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.js +60 -0
- package/dist/core/bash-jobs.d.ts +20 -8
- package/dist/core/bash-jobs.js +40 -21
- package/dist/core/canvas/types.d.ts +4 -2
- package/dist/core/command.js +1 -1
- package/dist/core/fault-classifier.d.ts +7 -1
- package/dist/core/fault-classifier.js +9 -4
- package/dist/core/provider-failure.d.ts +30 -0
- package/dist/core/provider-failure.js +86 -0
- package/dist/core/runtime/broker/event-projection.d.ts +0 -3
- package/dist/core/runtime/broker/event-projection.js +2 -15
- package/dist/core/runtime/broker/fault-retry.js +16 -0
- package/dist/core/runtime/broker-persona-guidance.js +12 -0
- package/dist/core/runtime/broker.js +0 -5
- package/dist/core/runtime/fault.d.ts +2 -1
- package/dist/core/runtime/fault.js +39 -1
- package/dist/core/runtime/host.js +10 -1
- package/dist/core/runtime/kickoff.d.ts +3 -0
- package/dist/core/runtime/kickoff.js +24 -4
- package/dist/core/runtime/reopen.d.ts +2 -2
- package/dist/core/runtime/reopen.js +6 -4
- package/dist/core/runtime/revive.js +3 -3
- package/dist/core/runtime/spawn.js +5 -6
- package/dist/core/runtime/tmux-bindings.js +14 -3
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/core/worktree-close.d.ts +4 -0
- package/dist/core/worktree-close.js +228 -0
- package/dist/core/worktree-containment.d.ts +14 -0
- package/dist/core/worktree-containment.js +48 -0
- package/dist/core/worktree-mutation-async.d.ts +13 -0
- package/dist/core/worktree-mutation-async.js +281 -0
- package/dist/core/worktree-sweep.js +24 -82
- package/dist/core/worktree.d.ts +1 -0
- package/dist/core/worktree.js +7 -4
- package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
- package/dist/daemon/api/__tests__/reopen-delivery.test.js +31 -0
- package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +36 -4
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +33 -14
- package/dist/daemon/api/handlers/reports.js +3 -2
- package/dist/daemon/api/handlers/worktree.js +7 -6
- package/dist/daemon/fleet.d.ts +1 -1
- package/dist/daemon/fleet.js +30 -12
- package/dist/daemon/manage.d.ts +8 -5
- package/dist/daemon/manage.js +63 -40
- package/dist/daemon/reconcilers/broker-supervision.js +10 -1
- package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
- package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
- package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
- package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
- package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
- package/dist/pi-extensions/canvas-bash-valve.js +73 -43
- package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -573,6 +573,14 @@ export class BrokerSupervisionReconciler {
|
|
|
573
573
|
const name = meta !== null ? fullName(meta) : id;
|
|
574
574
|
const isAuth = fault.kind === 'auth';
|
|
575
575
|
const firstLine = (fault.message.split('\n')[0] ?? '').slice(0, 200);
|
|
576
|
+
const providerError = fault.providerError;
|
|
577
|
+
const providerDetail = providerError === undefined
|
|
578
|
+
? ''
|
|
579
|
+
: ` Provider: ${providerError.provider}/${providerError.model}.` +
|
|
580
|
+
(providerError.status === undefined ? '' : ` HTTP status: ${providerError.status}.`) +
|
|
581
|
+
(providerError.errno === undefined ? '' : ` Errno: ${providerError.errno}.`) +
|
|
582
|
+
(providerError.code === undefined ? '' : ` Code: ${providerError.code}.`) +
|
|
583
|
+
(providerError.body === undefined ? '' : ` Body: ${providerError.body.slice(0, 200)}.`);
|
|
576
584
|
const terminalOverflow = fault.kind === 'context-overflow';
|
|
577
585
|
const label = terminalOverflow
|
|
578
586
|
? `Child failed — ${name} (${id}) exceeded its model context window and is now dead. ` +
|
|
@@ -582,7 +590,7 @@ export class BrokerSupervisionReconciler {
|
|
|
582
590
|
(isAuth
|
|
583
591
|
? `Its provider credential is dead — re-authenticate (\`/login\`, or re-auth crtr); the child recovers on its next wake once the auth reloads. `
|
|
584
592
|
: ``) +
|
|
585
|
-
`Error: ${firstLine}`;
|
|
593
|
+
`Error: ${firstLine}.${providerDetail}`;
|
|
586
594
|
if (terminalOverflow) {
|
|
587
595
|
try {
|
|
588
596
|
transition(id, 'crash', {
|
|
@@ -618,6 +626,7 @@ export class BrokerSupervisionReconciler {
|
|
|
618
626
|
child: id,
|
|
619
627
|
kind: fault.kind,
|
|
620
628
|
message: firstLine,
|
|
629
|
+
...(providerError === undefined ? {} : { provider_error: providerError }),
|
|
621
630
|
});
|
|
622
631
|
}
|
|
623
632
|
async handleAuthReload(fleet) {
|
|
@@ -30,6 +30,12 @@ const TERMINAL_STATUSES = ['done', 'dead', 'canceled'];
|
|
|
30
30
|
function isTerminal(row) {
|
|
31
31
|
return TERMINAL_STATUSES.includes(row.status);
|
|
32
32
|
}
|
|
33
|
+
/** A closed record already has an explicit close disposition. An open record
|
|
34
|
+
* reaches the sweep only when its owner published a final result; a crash or
|
|
35
|
+
* cancellation leaves its managed checkout available for explicit recovery. */
|
|
36
|
+
function hasSweepDisposition(row, wt) {
|
|
37
|
+
return wt?.state === 'closed' || (wt?.state === 'open' && row.status === 'done' && row.final_report !== null);
|
|
38
|
+
}
|
|
33
39
|
/** Is this node's engine definitively gone?
|
|
34
40
|
*
|
|
35
41
|
* Terminal status alone is not enough: `push final` marks a node `done` while
|
|
@@ -49,6 +55,8 @@ function engineIsGone(row, fleet, snapshot) {
|
|
|
49
55
|
return false;
|
|
50
56
|
if (!isTerminal(row))
|
|
51
57
|
return false;
|
|
58
|
+
if (!hasSweepDisposition(row, getNode(row.node_id)?.managed_worktree))
|
|
59
|
+
return false;
|
|
52
60
|
return recordedPidLiveness(row.pi_pid, row.pi_pid_identity, snapshot) === 'dead';
|
|
53
61
|
}
|
|
54
62
|
/** The eligibility recheck the reconciliation runs immediately before EVERY Git
|
|
@@ -70,6 +78,8 @@ function eligibilityGuard(row, fleet) {
|
|
|
70
78
|
const current = getRow(nodeId);
|
|
71
79
|
if (current === null || !isTerminal(current) || current.frozen_at !== null)
|
|
72
80
|
return false;
|
|
81
|
+
if (!hasSweepDisposition(current, getNode(nodeId)?.managed_worktree))
|
|
82
|
+
return false;
|
|
73
83
|
return current.pi_pid === pid && current.pi_pid_identity === identity;
|
|
74
84
|
},
|
|
75
85
|
};
|
|
@@ -120,6 +130,8 @@ export class ManagedWorktreeSweepReconciler {
|
|
|
120
130
|
}
|
|
121
131
|
if (!isReconcilable(wt) || wt == null)
|
|
122
132
|
continue;
|
|
133
|
+
if (!hasSweepDisposition(row, wt))
|
|
134
|
+
continue;
|
|
123
135
|
if (!isDueForSweep(wt, now))
|
|
124
136
|
continue;
|
|
125
137
|
due.push(row.node_id);
|
|
@@ -7,12 +7,6 @@ export interface NodeLifecycleContext {
|
|
|
7
7
|
rows: readonly NodeRow[];
|
|
8
8
|
fleet: FleetRegistry;
|
|
9
9
|
}
|
|
10
|
-
/** Whether this row should have a live engine right now. An active row always
|
|
11
|
-
* should — that status IS the claim that it is executing. An idle row was
|
|
12
|
-
* deliberately released and holds no engine on purpose: only mail it has not
|
|
13
|
-
* seen brings one back, because a live obligation keeps a node WAKEABLE rather
|
|
14
|
-
* than awake, and relaunching on one would re-release it every tick. */
|
|
15
|
-
export declare function wantsExecution(row: NodeRow): boolean;
|
|
16
10
|
export declare class NodeLifecycleReconciler {
|
|
17
11
|
private readonly freezeLane;
|
|
18
12
|
run(now: number, ctx: NodeLifecycleContext): Promise<FrozenCounts>;
|
|
@@ -12,7 +12,6 @@ import { isSafeNodeId } from '../../../core/canvas/paths.js';
|
|
|
12
12
|
import { recordedPidLiveness } from '../../../core/canvas/pid.js';
|
|
13
13
|
import { emitEvent } from '../../../core/events/emit.js';
|
|
14
14
|
import { operationIdContext } from '../../../core/events/operation-id.js';
|
|
15
|
-
import { hasUnseenWakeMail } from '../../../core/feed/inbox.js';
|
|
16
15
|
import { hasCleanAbort, isBusy } from '../../../core/runtime/busy.js';
|
|
17
16
|
import { isProfilePaused } from '../../../core/profiles/manifest.js';
|
|
18
17
|
import { classifyDeadNode } from '../../fleet.js';
|
|
@@ -20,21 +19,10 @@ import { applyParkEvent, hasPendingWake, parkEvent } from '../live-obligation.js
|
|
|
20
19
|
import { FreezeLaneReconciler } from './freeze-lane.js';
|
|
21
20
|
import { isInBackoff } from './respawn-policy.js';
|
|
22
21
|
import { handleTerminatingRow } from './terminating.js';
|
|
22
|
+
import { wantsExecution } from './wants-execution.js';
|
|
23
23
|
function isTerminal(row) {
|
|
24
24
|
return row.status === 'done' || row.status === 'canceled' || row.status === 'dead';
|
|
25
25
|
}
|
|
26
|
-
/** Whether this row should have a live engine right now. An active row always
|
|
27
|
-
* should — that status IS the claim that it is executing. An idle row was
|
|
28
|
-
* deliberately released and holds no engine on purpose: only mail it has not
|
|
29
|
-
* seen brings one back, because a live obligation keeps a node WAKEABLE rather
|
|
30
|
-
* than awake, and relaunching on one would re-release it every tick. */
|
|
31
|
-
export function wantsExecution(row) {
|
|
32
|
-
if (row.status === 'active')
|
|
33
|
-
return true;
|
|
34
|
-
if (row.status !== 'idle')
|
|
35
|
-
return false;
|
|
36
|
-
return hasUnseenWakeMail(row.node_id);
|
|
37
|
-
}
|
|
38
26
|
export class NodeLifecycleReconciler {
|
|
39
27
|
freezeLane = new FreezeLaneReconciler();
|
|
40
28
|
async run(now, ctx) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { NodeRow } from '../../../core/canvas/index.js';
|
|
2
|
+
/** Whether the lifecycle tick must ensure this row has an engine. The durable
|
|
3
|
+
* message handler calls this after appending its entry: that append makes the
|
|
4
|
+
* idle branch's unseen-wake-mail check true for a wake-capable entry. */
|
|
5
|
+
export declare function wantsExecution(row: Pick<NodeRow, 'node_id' | 'status'>): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { hasUnseenWakeMail } from '../../../core/feed/inbox.js';
|
|
2
|
+
/** Whether the lifecycle tick must ensure this row has an engine. The durable
|
|
3
|
+
* message handler calls this after appending its entry: that append makes the
|
|
4
|
+
* idle branch's unseen-wake-mail check true for a wake-capable entry. */
|
|
5
|
+
export function wantsExecution(row) {
|
|
6
|
+
if (row.status === 'active')
|
|
7
|
+
return true;
|
|
8
|
+
if (row.status !== 'idle')
|
|
9
|
+
return false;
|
|
10
|
+
return hasUnseenWakeMail(row.node_id);
|
|
11
|
+
}
|
|
@@ -5,12 +5,16 @@
|
|
|
5
5
|
import { test, before, after, beforeEach, afterEach } from 'node:test';
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
7
|
import { existsSync, mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { createServer } from 'node:http';
|
|
8
9
|
import { tmpdir } from 'node:os';
|
|
9
10
|
import { join } from 'node:path';
|
|
10
11
|
import { registerCanvasContextIntro, CONTEXT_INTRO_CUSTOM_TYPE, } from '../canvas-context-intro.js';
|
|
11
12
|
import { registerCanvasDocSubstrate } from '../canvas-doc-substrate.js';
|
|
12
13
|
import { buildContextBearings as buildContextIntro } from '../../core/runtime/bearings.js';
|
|
13
14
|
import { installPersonaTransitionGate } from '../../core/runtime/broker-persona-guidance.js';
|
|
15
|
+
import { FaultRetry } from '../../core/runtime/broker/fault-retry.js';
|
|
16
|
+
import { readFault } from '../../core/runtime/fault.js';
|
|
17
|
+
import { ApiError } from '../../api/errors.js';
|
|
14
18
|
import { createNode, deleteNode, getNode, updateNode } from '../../core/canvas/canvas.js';
|
|
15
19
|
import { apiSocketPath } from '../../core/canvas/paths.js';
|
|
16
20
|
import { createApiServer } from '../../daemon/api/server.js';
|
|
@@ -32,6 +36,23 @@ async function waitForSocket(path) {
|
|
|
32
36
|
}
|
|
33
37
|
throw new Error(`api socket never bound at ${path}`);
|
|
34
38
|
}
|
|
39
|
+
async function listenUnavailableApi(path) {
|
|
40
|
+
const unavailable = createServer((_request, response) => {
|
|
41
|
+
response.writeHead(503, { 'content-type': 'application/json' });
|
|
42
|
+
response.end(JSON.stringify({ error: { code: 'daemon_unavailable', message: 'local daemon connection refused' } }));
|
|
43
|
+
});
|
|
44
|
+
await new Promise((resolve, reject) => {
|
|
45
|
+
unavailable.once('error', reject);
|
|
46
|
+
unavailable.listen(path, () => {
|
|
47
|
+
unavailable.off('error', reject);
|
|
48
|
+
resolve();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
return unavailable;
|
|
52
|
+
}
|
|
53
|
+
function closeHttpServer(server) {
|
|
54
|
+
return new Promise((resolve, reject) => server.close((error) => error === undefined ? resolve() : reject(error)));
|
|
55
|
+
}
|
|
35
56
|
function node(id, over = {}) {
|
|
36
57
|
return {
|
|
37
58
|
node_id: id,
|
|
@@ -322,6 +343,68 @@ test('child /new commits its baseline before ack and the provider gate delivers
|
|
|
322
343
|
process.env['CRTR_PROFILE_ID'] = previousProfile;
|
|
323
344
|
}
|
|
324
345
|
});
|
|
346
|
+
test('a typed local API error remains a retryable broker-to-daemon fault after settlement', async () => {
|
|
347
|
+
createNode(node('persona-transport', {
|
|
348
|
+
persona_ack: { kind: 'review', mode: 'base', lifecycle: 'terminal' },
|
|
349
|
+
}));
|
|
350
|
+
await server.close();
|
|
351
|
+
let unavailable;
|
|
352
|
+
try {
|
|
353
|
+
unavailable = await listenUnavailableApi(apiSocketPath());
|
|
354
|
+
const messages = [];
|
|
355
|
+
const agent = {
|
|
356
|
+
state: { messages },
|
|
357
|
+
transformContext: async (context) => context,
|
|
358
|
+
};
|
|
359
|
+
installPersonaTransitionGate('persona-transport', {
|
|
360
|
+
agent,
|
|
361
|
+
sessionManager: {
|
|
362
|
+
appendCustomMessageEntry: () => undefined,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
await assert.rejects(() => agent.transformContext([{ role: 'user', content: 'continue' }]), (error) => error instanceof ApiError && error.status === 503 && error.code === 'daemon_unavailable');
|
|
366
|
+
assert.deepEqual(readFault('persona-transport'), {
|
|
367
|
+
link: 'broker↔crtrd',
|
|
368
|
+
op: 'persona transition',
|
|
369
|
+
kind: 'connection',
|
|
370
|
+
retry: { disposition: 'manual' },
|
|
371
|
+
message: 'local daemon connection refused',
|
|
372
|
+
since: readFault('persona-transport')?.since,
|
|
373
|
+
operation_id: readFault('persona-transport')?.operation_id,
|
|
374
|
+
}, 'the typed daemon transport error is recorded before Pi turns it into a generic agent error');
|
|
375
|
+
const session = {
|
|
376
|
+
sessionManager: { getLeafId: () => 'persona-transport-leaf' },
|
|
377
|
+
model: undefined,
|
|
378
|
+
};
|
|
379
|
+
const generation = {
|
|
380
|
+
id: 1,
|
|
381
|
+
session,
|
|
382
|
+
fallback: { blocked: false },
|
|
383
|
+
stagedProviderAgentEnd: {
|
|
384
|
+
messages: [{ role: 'assistant', stopReason: 'error', errorMessage: 'local daemon connection refused' }],
|
|
385
|
+
},
|
|
386
|
+
stagedWatchdogAbort: false,
|
|
387
|
+
stagedRefreshAbort: false,
|
|
388
|
+
stagedOverflowFailure: null,
|
|
389
|
+
executingTools: new Set(),
|
|
390
|
+
};
|
|
391
|
+
new FaultRetry({
|
|
392
|
+
nodeId: 'persona-transport',
|
|
393
|
+
currentSession: () => session,
|
|
394
|
+
installedGeneration: () => generation,
|
|
395
|
+
}).publishSettledProviderFault(generation);
|
|
396
|
+
const fault = readFault('persona-transport');
|
|
397
|
+
assert.equal(fault?.link, 'broker↔crtrd');
|
|
398
|
+
assert.equal(fault?.kind, 'connection');
|
|
399
|
+
assert.equal(fault?.retry.disposition, 'manual');
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
if (unavailable !== undefined)
|
|
403
|
+
await closeHttpServer(unavailable);
|
|
404
|
+
server = createApiServer();
|
|
405
|
+
await waitForSocket(apiSocketPath());
|
|
406
|
+
}
|
|
407
|
+
});
|
|
325
408
|
test('resume idempotency uses the exact details stamp independent of block text', async () => {
|
|
326
409
|
createNode(node('stampD'));
|
|
327
410
|
// The exact node-id stamp is authoritative even when prose carries no id.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { backgroundRunningBashJobs, bashJobsDir, bashJobPaths, readJobPgid, stopBackgroundBashJob } from '../../../core/bash-jobs.js';
|
|
8
|
+
import { capturePidIdentities, isPidAlive } from '../../../core/canvas/pid.js';
|
|
9
|
+
import { BACKGROUND_BASH_LOG_TRUNCATION_MARKER, MAX_BACKGROUND_BASH_LOG_BYTES, createValveOperations } from '../../canvas-bash-valve.js';
|
|
10
|
+
const POLL_MS = 20;
|
|
11
|
+
function sleep(ms) {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
}
|
|
14
|
+
async function waitFor(condition, message) {
|
|
15
|
+
const deadline = Date.now() + 4_000;
|
|
16
|
+
while (Date.now() < deadline) {
|
|
17
|
+
if (condition())
|
|
18
|
+
return;
|
|
19
|
+
await sleep(POLL_MS);
|
|
20
|
+
}
|
|
21
|
+
throw new Error(message);
|
|
22
|
+
}
|
|
23
|
+
async function backgroundOneJob(contextDir) {
|
|
24
|
+
await waitFor(() => backgroundRunningBashJobs(contextDir).length === 1, 'bash job did not start');
|
|
25
|
+
await waitFor(() => {
|
|
26
|
+
try {
|
|
27
|
+
return readdirSync(bashJobsDir(contextDir)).length === 1;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}, 'background job directory was not created');
|
|
33
|
+
return readdirSync(bashJobsDir(contextDir))[0];
|
|
34
|
+
}
|
|
35
|
+
function killOwnedProcess(pid) {
|
|
36
|
+
if (pid === undefined)
|
|
37
|
+
return;
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, 'SIGKILL');
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// The test already stopped this exact process.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
test('background cancellation reaches a descendant that changed process groups', { timeout: 10_000 }, async () => {
|
|
46
|
+
const root = mkdtempSync(join(tmpdir(), 'crtr-bash-tree-'));
|
|
47
|
+
const contextDir = join(root, 'context');
|
|
48
|
+
const escapedPidPath = join(root, 'escaped.pid');
|
|
49
|
+
let escapedPid;
|
|
50
|
+
try {
|
|
51
|
+
const python = [
|
|
52
|
+
'import os, pathlib, time',
|
|
53
|
+
'os.setpgrp()',
|
|
54
|
+
`pathlib.Path(${JSON.stringify(escapedPidPath)}).write_text(f"{os.getpid()} {os.getpgrp()}")`,
|
|
55
|
+
'time.sleep(30)',
|
|
56
|
+
].join('; ');
|
|
57
|
+
const run = createValveOperations('test-node', contextDir).exec(`python3 -c ${JSON.stringify(python)}`, root, { onData: () => { } });
|
|
58
|
+
const jobId = await backgroundOneJob(contextDir);
|
|
59
|
+
await run;
|
|
60
|
+
await waitFor(() => existsSync(escapedPidPath), 'escaped descendant did not start');
|
|
61
|
+
const [pidText, pgidText] = readFileSync(escapedPidPath, 'utf8').split(' ');
|
|
62
|
+
escapedPid = Number(pidText);
|
|
63
|
+
const escapedPgid = Number(pgidText);
|
|
64
|
+
assert.ok(Number.isInteger(escapedPid) && escapedPid > 1);
|
|
65
|
+
assert.notEqual(escapedPgid, readJobPgid(contextDir, jobId));
|
|
66
|
+
const stopped = await stopBackgroundBashJob(contextDir, jobId);
|
|
67
|
+
assert.equal(stopped.kind, 'stopped');
|
|
68
|
+
assert.equal(stopped.signaled, true);
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(escapedPid, 0);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}, 'escaped descendant survived background cancellation');
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
killOwnedProcess(escapedPid);
|
|
81
|
+
rmSync(root, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
test('stale background metadata never signals a reused supervisor PID', async () => {
|
|
85
|
+
const root = mkdtempSync(join(tmpdir(), 'crtr-bash-stale-pid-'));
|
|
86
|
+
const contextDir = join(root, 'context');
|
|
87
|
+
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30_000)']);
|
|
88
|
+
try {
|
|
89
|
+
assert.notEqual(child.pid, undefined);
|
|
90
|
+
const actualIdentity = capturePidIdentities([child.pid])?.get(child.pid);
|
|
91
|
+
assert.notEqual(actualIdentity, undefined);
|
|
92
|
+
const paths = bashJobPaths(contextDir, '1234567890-stale');
|
|
93
|
+
mkdirSync(paths.dir, { recursive: true });
|
|
94
|
+
writeFileSync(paths.jobBg, '');
|
|
95
|
+
writeFileSync(paths.jobPgid, String(child.pid));
|
|
96
|
+
writeFileSync(paths.jobPgidIdentity, 'stale-supervisor');
|
|
97
|
+
const stopped = await stopBackgroundBashJob(contextDir, paths.jobId);
|
|
98
|
+
assert.deepEqual(stopped, { kind: 'stopped', signaled: false, pgid: child.pid, logPath: paths.jobLog });
|
|
99
|
+
assert.equal(isPidAlive(child.pid), true, 'a process with a reused supervisor PID was signaled');
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
child.kill('SIGKILL');
|
|
103
|
+
rmSync(root, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
test('background log retains a capped prefix, truncation marker, and does not grow afterward', { timeout: 10_000 }, async () => {
|
|
107
|
+
const root = mkdtempSync(join(tmpdir(), 'crtr-bash-log-'));
|
|
108
|
+
const contextDir = join(root, 'context');
|
|
109
|
+
try {
|
|
110
|
+
const writer = [
|
|
111
|
+
'const chunk = Buffer.alloc(1024 * 1024, 120)',
|
|
112
|
+
'setTimeout(() => {',
|
|
113
|
+
' for (let i = 0; i < 11; i += 1) process.stdout.write(chunk)',
|
|
114
|
+
' setTimeout(() => process.stdout.write("after-cap"), 200)',
|
|
115
|
+
'}, 200)',
|
|
116
|
+
'setTimeout(() => {}, 1_000)',
|
|
117
|
+
].join('; ');
|
|
118
|
+
const run = createValveOperations('test-node', contextDir).exec(`${process.execPath} -e ${JSON.stringify(writer)}`, root, { onData: () => { } });
|
|
119
|
+
const jobId = await backgroundOneJob(contextDir);
|
|
120
|
+
await run;
|
|
121
|
+
const { jobExit, jobLog } = bashJobPaths(contextDir, jobId);
|
|
122
|
+
await waitFor(() => existsSync(jobLog) && readFileSync(jobLog, 'utf8').includes(BACKGROUND_BASH_LOG_TRUNCATION_MARKER), 'log did not record truncation');
|
|
123
|
+
const sizeAtCap = statSync(jobLog).size;
|
|
124
|
+
assert.ok(sizeAtCap <= MAX_BACKGROUND_BASH_LOG_BYTES);
|
|
125
|
+
await sleep(500);
|
|
126
|
+
assert.equal(statSync(jobLog).size, sizeAtCap, 'discarded output continued growing the log');
|
|
127
|
+
await waitFor(() => existsSync(jobExit), 'writer did not finish');
|
|
128
|
+
assert.equal(readFileSync(jobExit, 'utf8').trim(), '0', 'collector changed the writer exit status');
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
rmSync(root, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createBashToolDefinition, type BashOperations } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
export declare const MAX_BACKGROUND_BASH_LOG_BYTES: number;
|
|
4
|
+
export declare const BACKGROUND_BASH_LOG_TRUNCATION_MARKER = "\n[background bash job output truncated at 10 MiB; remaining output discarded]\n";
|
|
3
5
|
/** The valve's BashOperations backend. Each tool execution closes over its own
|
|
4
6
|
* `takePurpose` and its own preview-result path, so concurrent calls cannot
|
|
5
7
|
* exchange labels or result mirrors. The purpose is consumed even when the
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
// SIGPIPE, and its exit code is observable by nobody. So every command is
|
|
19
19
|
// file-backed from birth via a tiny detached supervisor (a `bash -c` process
|
|
20
20
|
// group leader) that redirects the real command's stdout+stderr to a log
|
|
21
|
-
// file and records its exit code to a sentinel
|
|
22
|
-
// tail-able, independent of whether this pi process
|
|
21
|
+
// file through a bounded collector and records its exit code to a sentinel
|
|
22
|
+
// file — both readable and tail-able, independent of whether this pi process
|
|
23
|
+
// is even still alive.
|
|
23
24
|
//
|
|
24
25
|
// Deviation from upstream: this backend pins `bash` as the shell (upstream
|
|
25
26
|
// consults user shell config via non-exported helpers). Acceptable — crtr's
|
|
@@ -30,8 +31,9 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmS
|
|
|
30
31
|
import { homedir } from 'node:os';
|
|
31
32
|
import { dirname } from 'node:path';
|
|
32
33
|
import { performance } from 'node:perf_hooks';
|
|
33
|
-
import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId, normalizeBashJobPurpose, readBashNiceness, writeJobDeadlineMs } from '../core/bash-jobs.js';
|
|
34
|
+
import { backgroundBashJob, bashJobPaths, cancelBashJobProcessTree, formatBashElapsed, newBashJobId, normalizeBashJobPurpose, readBashNiceness, writeJobDeadlineMs } from '../core/bash-jobs.js';
|
|
34
35
|
import { bashCommandRefusal } from '../core/bash-guard.js';
|
|
36
|
+
import { capturePidIdentities } from '../core/canvas/pid.js';
|
|
35
37
|
import { readConfig } from '../core/config.js';
|
|
36
38
|
import { PREVIEW_RESULT_PATH_ENV, previewResultPath } from '../core/preview-result-path.js';
|
|
37
39
|
import { resolveAdmittedHostCommand } from '../core/runtime/spawn-env.js';
|
|
@@ -43,18 +45,44 @@ const POLL_MS = 200;
|
|
|
43
45
|
function resolveValveMs() {
|
|
44
46
|
return envBashValveMs();
|
|
45
47
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
48
|
+
export const MAX_BACKGROUND_BASH_LOG_BYTES = 10 * 1024 * 1024;
|
|
49
|
+
export const BACKGROUND_BASH_LOG_TRUNCATION_MARKER = '\n[background bash job output truncated at 10 MiB; remaining output discarded]\n';
|
|
50
|
+
// This process owns one bounded prefix of the command stream. It keeps
|
|
51
|
+
// draining after the cap so the command never sees a closed pipe/SIGPIPE, and
|
|
52
|
+
// uses synchronous fixed-size stdin chunks plus direct file writes so neither
|
|
53
|
+
// the collector nor the log can grow without bound.
|
|
54
|
+
const LOG_COLLECTOR_SCRIPT = [
|
|
55
|
+
'const fs = require("node:fs");',
|
|
56
|
+
`const cap = ${MAX_BACKGROUND_BASH_LOG_BYTES};`,
|
|
57
|
+
`const marker = Buffer.from(${JSON.stringify(BACKGROUND_BASH_LOG_TRUNCATION_MARKER)});`,
|
|
58
|
+
'const prefixCap = cap - marker.length;',
|
|
59
|
+
'const fd = fs.openSync(process.argv[1], "w");',
|
|
60
|
+
'let written = 0;',
|
|
61
|
+
'let truncated = false;',
|
|
62
|
+
'process.stdin.on("data", (chunk) => {',
|
|
63
|
+
' if (truncated) return;',
|
|
64
|
+
' const available = prefixCap - written;',
|
|
65
|
+
' if (available > 0) {',
|
|
66
|
+
' const prefix = chunk.subarray(0, available);',
|
|
67
|
+
' fs.writeSync(fd, prefix);',
|
|
68
|
+
' written += prefix.length;',
|
|
69
|
+
' if (prefix.length === chunk.length) return;',
|
|
70
|
+
' }',
|
|
71
|
+
' fs.writeSync(fd, marker);',
|
|
72
|
+
' truncated = true;',
|
|
73
|
+
'});',
|
|
74
|
+
'process.stdin.on("end", () => fs.closeSync(fd));',
|
|
75
|
+
].join(' ');
|
|
76
|
+
// The detached supervisor owns the command and its bounded stream collector.
|
|
77
|
+
// Positional argv (never interpolated into the script — the command text lives
|
|
78
|
+
// only in cmd.sh):
|
|
52
79
|
// $1 cmd.sh $2 job.log $3 job.exit $4 job.run
|
|
53
80
|
// $5 job.done $6 job.bg $7 jobId $8 nodeId $9 shell path
|
|
54
|
-
// ${10} niceness
|
|
81
|
+
// ${10} niceness ${11} node path
|
|
55
82
|
//
|
|
56
83
|
// `--` before the paths is a dummy $0 (a standard `bash -c script -- args`
|
|
57
84
|
// idiom) so the real payload lands at $1 onward.
|
|
85
|
+
//
|
|
58
86
|
// The elapsed rendering mirrors formatBashElapsed() in core/bash-jobs.ts
|
|
59
87
|
// (`12s` / `3m 40s` / `1h 04m`) so the completion message reads the same as the
|
|
60
88
|
// handoff notice; it measures TOTAL runtime, foreground portion included.
|
|
@@ -64,8 +92,8 @@ function resolveValveMs() {
|
|
|
64
92
|
const SUPERVISOR_SCRIPT = [
|
|
65
93
|
'SECONDS=0',
|
|
66
94
|
'n=$(command -v nice)',
|
|
67
|
-
'${n:+"$n" -n "${10}"} "${9}" "$1"
|
|
68
|
-
'ec
|
|
95
|
+
'${n:+"$n" -n "${10}"} "${9}" "$1" 2>&1 | "${11}" -e ' + JSON.stringify(LOG_COLLECTOR_SCRIPT) + ' "$2"',
|
|
96
|
+
'ec=${PIPESTATUS[0]}',
|
|
69
97
|
'd=$SECONDS',
|
|
70
98
|
'echo "$ec" > "$3"',
|
|
71
99
|
'if [ "$d" -lt 60 ]; then',
|
|
@@ -107,25 +135,9 @@ function installTerminationHandoff() {
|
|
|
107
135
|
if (process.platform !== 'win32')
|
|
108
136
|
process.prependListener('SIGHUP', backgroundLiveJobsForTermination);
|
|
109
137
|
}
|
|
110
|
-
|
|
111
|
-
try {
|
|
112
|
-
process.kill(-pgid, 'SIGTERM');
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
/* already gone */
|
|
116
|
-
}
|
|
117
|
-
const killer = setTimeout(() => {
|
|
118
|
-
try {
|
|
119
|
-
process.kill(-pgid, 'SIGKILL');
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
/* already gone */
|
|
123
|
-
}
|
|
124
|
-
}, 2000);
|
|
125
|
-
killer.unref?.();
|
|
126
|
-
}
|
|
138
|
+
const TAIL_CHUNK_BYTES = 64 * 1024;
|
|
127
139
|
/** Read bytes appended to `path` since `offset`, feed them to onData, return
|
|
128
|
-
*
|
|
140
|
+
* the new offset. Best-effort — a not-yet-created log file is not an error. */
|
|
129
141
|
function tailOnce(path, offset, onData) {
|
|
130
142
|
let stat;
|
|
131
143
|
try {
|
|
@@ -138,10 +150,16 @@ function tailOnce(path, offset, onData) {
|
|
|
138
150
|
return offset;
|
|
139
151
|
const fd = openSync(path, 'r');
|
|
140
152
|
try {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
153
|
+
while (offset < stat.size) {
|
|
154
|
+
const length = Math.min(TAIL_CHUNK_BYTES, stat.size - offset);
|
|
155
|
+
const buf = Buffer.allocUnsafe(length);
|
|
156
|
+
const bytesRead = readSync(fd, buf, 0, length, offset);
|
|
157
|
+
if (bytesRead === 0)
|
|
158
|
+
break;
|
|
159
|
+
onData(buf.subarray(0, bytesRead));
|
|
160
|
+
offset += bytesRead;
|
|
161
|
+
}
|
|
162
|
+
return offset;
|
|
145
163
|
}
|
|
146
164
|
finally {
|
|
147
165
|
closeSync(fd);
|
|
@@ -211,6 +229,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
|
|
|
211
229
|
let offset = 0;
|
|
212
230
|
let settled = false;
|
|
213
231
|
let pgid;
|
|
232
|
+
let pgidIdentity;
|
|
214
233
|
const startedAt = performance.now();
|
|
215
234
|
const valveMs = resolveValveMs();
|
|
216
235
|
let interval;
|
|
@@ -245,7 +264,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
|
|
|
245
264
|
const notice = `\n[backgrounded as job ${paths.jobId} after ${elapsed} in the foreground${auto ? ' (auto)' : ''}. ` +
|
|
246
265
|
deadlineNotice +
|
|
247
266
|
`It will interrupt you with a message when it exits. ` +
|
|
248
|
-
`Watch: tail -f ${paths.jobLog} ; stop: kill
|
|
267
|
+
`Watch: tail -f ${paths.jobLog} ; stop: crtr node bash kill ${paths.jobId}]`;
|
|
249
268
|
onData(Buffer.from(notice));
|
|
250
269
|
finish(() => resolve({ exitCode: 0 }));
|
|
251
270
|
// Backgrounded — job dir persists (inspectable); no cleanup here.
|
|
@@ -279,8 +298,8 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
|
|
|
279
298
|
// finish the job.run → job.done transition before cleanup.
|
|
280
299
|
if (existsSync(paths.jobExit))
|
|
281
300
|
return;
|
|
282
|
-
if (pgid)
|
|
283
|
-
|
|
301
|
+
if (pgid !== undefined && pgidIdentity !== undefined)
|
|
302
|
+
void cancelBashJobProcessTree(pgid, pgidIdentity);
|
|
284
303
|
finish(() => reject(new Error('aborted')));
|
|
285
304
|
cleanupJobDir(paths.dir);
|
|
286
305
|
}
|
|
@@ -302,7 +321,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
|
|
|
302
321
|
childEnv = { ...childEnv, [PREVIEW_RESULT_PATH_ENV]: previewPath };
|
|
303
322
|
}
|
|
304
323
|
const shellPath = resolveAdmittedHostCommand('bash', childEnv, execution.cwd);
|
|
305
|
-
const child = spawn(shellPath, ['-c', SUPERVISOR_SCRIPT, '--', paths.cmdSh, paths.jobLog, paths.jobExit, paths.jobRun, paths.jobDone, paths.jobBg, paths.jobId, nodeId, shellPath, String(readBashNiceness(contextDir))], {
|
|
324
|
+
const child = spawn(shellPath, ['-c', SUPERVISOR_SCRIPT, '--', paths.cmdSh, paths.jobLog, paths.jobExit, paths.jobRun, paths.jobDone, paths.jobBg, paths.jobId, nodeId, shellPath, String(readBashNiceness(contextDir)), process.execPath], {
|
|
306
325
|
cwd: execution.cwd,
|
|
307
326
|
detached: true,
|
|
308
327
|
stdio: 'ignore',
|
|
@@ -313,11 +332,22 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
|
|
|
313
332
|
cleanupJobDir(paths.dir);
|
|
314
333
|
});
|
|
315
334
|
pgid = child.pid;
|
|
316
|
-
// The detached supervisor
|
|
317
|
-
//
|
|
318
|
-
//
|
|
335
|
+
// The detached supervisor starts this process tree. Its launch-time
|
|
336
|
+
// identity lets cancellation reject a stale PID that now belongs to an
|
|
337
|
+
// unrelated process; it is not a containment guarantee.
|
|
319
338
|
if (pgid !== undefined) {
|
|
339
|
+
pgidIdentity = capturePidIdentities([pgid])?.get(pgid);
|
|
340
|
+
if (pgidIdentity === undefined) {
|
|
341
|
+
try {
|
|
342
|
+
process.kill(-pgid, 'SIGKILL');
|
|
343
|
+
}
|
|
344
|
+
catch { /* the just-started supervisor already exited */ }
|
|
345
|
+
finish(() => reject(new Error(`bash job ${paths.jobId} started without a process identity`)));
|
|
346
|
+
cleanupJobDir(paths.dir);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
320
349
|
writeFileSync(paths.jobPgid, String(pgid));
|
|
350
|
+
writeFileSync(paths.jobPgidIdentity, pgidIdentity);
|
|
321
351
|
liveForegroundJobs.add(paths);
|
|
322
352
|
}
|
|
323
353
|
child.unref();
|
|
@@ -338,8 +368,8 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
|
|
|
338
368
|
const elapsedMs = performance.now() - startedAt;
|
|
339
369
|
if (timeout !== undefined && timeout > 0) {
|
|
340
370
|
if (elapsedMs >= timeout * 1000) {
|
|
341
|
-
if (pgid)
|
|
342
|
-
|
|
371
|
+
if (pgid !== undefined && pgidIdentity !== undefined)
|
|
372
|
+
void cancelBashJobProcessTree(pgid, pgidIdentity);
|
|
343
373
|
finish(() => reject(new Error(`timeout:${timeout}`)));
|
|
344
374
|
cleanupJobDir(paths.dir);
|
|
345
375
|
}
|
|
@@ -344,8 +344,6 @@ class CanvasInboxWatcher {
|
|
|
344
344
|
* reports would render as bare refs instead of inlined bodies. One refresh
|
|
345
345
|
* per unknown node id keeps a genuinely missing node from stalling delivery. */
|
|
346
346
|
refreshForUnknownReportRefs() {
|
|
347
|
-
if (this.reportNodesRefreshing)
|
|
348
|
-
return true;
|
|
349
347
|
const unknown = [];
|
|
350
348
|
for (const entry of this.buffer) {
|
|
351
349
|
const refNodeId = reportRefNodeId(entry.ref);
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.305",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.305",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"workspaces": [
|
|
@@ -5206,17 +5206,17 @@
|
|
|
5206
5206
|
},
|
|
5207
5207
|
"packages/crouter-api": {
|
|
5208
5208
|
"name": "@north-light/crouter-api",
|
|
5209
|
-
"version": "0.3.
|
|
5209
|
+
"version": "0.3.305",
|
|
5210
5210
|
"license": "UNLICENSED"
|
|
5211
5211
|
},
|
|
5212
5212
|
"packages/crouter-env-docker": {
|
|
5213
5213
|
"name": "@north-light/crouter-env-docker",
|
|
5214
|
-
"version": "0.3.
|
|
5214
|
+
"version": "0.3.305",
|
|
5215
5215
|
"license": "UNLICENSED"
|
|
5216
5216
|
},
|
|
5217
5217
|
"packages/crouter-sdk": {
|
|
5218
5218
|
"name": "@north-light/crouter-sdk",
|
|
5219
|
-
"version": "0.3.
|
|
5219
|
+
"version": "0.3.305",
|
|
5220
5220
|
"license": "UNLICENSED",
|
|
5221
5221
|
"dependencies": {
|
|
5222
5222
|
"@north-light/crouter-api": "^0.3.295"
|