@north-light/crouter 0.3.301 → 0.3.303
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/dto/reports.d.ts +8 -8
- package/dist/clients/attach/viewer.js +394 -394
- package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +30 -1
- package/dist/commands/memory/read.js +12 -14
- package/dist/commands/node/lifecycle.js +7 -6
- package/dist/commands/push.js +4 -4
- package/dist/core/__tests__/bash-guard.test.d.ts +1 -0
- package/dist/core/__tests__/bash-guard.test.js +190 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
- package/dist/core/__tests__/integration/revive.test.js +3 -3
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +42 -0
- package/dist/core/__tests__/stop-guard.test.js +64 -0
- package/dist/core/bash-guard.d.ts +6 -0
- package/dist/core/bash-guard.js +393 -0
- package/dist/core/canvas/crons.d.ts +20 -4
- package/dist/core/canvas/crons.js +54 -7
- package/dist/core/memory-resolver.d.ts +3 -1
- package/dist/core/memory-resolver.js +3 -3
- package/dist/core/nested-stores.d.ts +1 -1
- package/dist/core/nested-stores.js +3 -3
- package/dist/core/runtime/bearings-render.js +1 -1
- package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
- package/dist/core/runtime/broker/fault-retry.js +66 -6
- package/dist/core/runtime/close.js +8 -6
- package/dist/core/runtime/stop-guard.js +6 -6
- package/dist/core/shell-segments.d.ts +42 -0
- package/dist/core/shell-segments.js +169 -0
- package/dist/core/substrate/surface-match.d.ts +0 -9
- package/dist/core/substrate/surface-match.js +4 -160
- package/dist/core/worktree.js +3 -6
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +21 -11
- package/dist/daemon/api/handlers/reports.js +1 -1
- package/dist/daemon/api/map.d.ts +1 -1
- package/dist/daemon/api/map.js +2 -2
- package/dist/daemon/cron/sinks.d.ts +1 -1
- package/dist/daemon/cron/sinks.js +2 -2
- package/dist/daemon/reconcilers/live-obligation.js +2 -2
- package/dist/pi-extensions/canvas-bash-valve.d.ts +0 -3
- package/dist/pi-extensions/canvas-bash-valve.js +2 -34
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
- package/dist/daemon/cron-sink.d.ts +0 -26
- package/dist/daemon/cron-sink.js +0 -43
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// container (its owning repo dir for `read`, its canonical routing anchor for
|
|
11
11
|
// `memory-read`).
|
|
12
12
|
import { basename, isAbsolute, matchesGlob, relative, sep } from 'node:path';
|
|
13
|
+
import { commandSegments, stripHeredocs } from '../shell-segments.js';
|
|
13
14
|
import { CRTR_DIR_NAME } from '../../types.js';
|
|
14
15
|
import { realpathOrSelf } from '../fs-utils.js';
|
|
15
16
|
import { evalCondition } from '../predicate.js';
|
|
@@ -102,129 +103,6 @@ export function matchesMemoryReadEntry(entry, routingAnchor, subject, name) {
|
|
|
102
103
|
return false;
|
|
103
104
|
return entry.match.some((g) => memoryReadGlobMatches(g, routingAnchor, name));
|
|
104
105
|
}
|
|
105
|
-
/** Split a command into shell segments on unquoted `;`, newline, `&&`, `||`,
|
|
106
|
-
* and `|`. A single `&`, subshell parentheses, and backticks are left inside
|
|
107
|
-
* their segment — this is a best-effort guardrail, not a shell parser.
|
|
108
|
-
* Heredoc bodies are a separate pre-match concern (see `stripHeredocs`). */
|
|
109
|
-
function splitCommandSegments(command) {
|
|
110
|
-
const segments = [];
|
|
111
|
-
let current = '';
|
|
112
|
-
let quote = null;
|
|
113
|
-
let escaped = false;
|
|
114
|
-
const finish = () => {
|
|
115
|
-
segments.push(current);
|
|
116
|
-
current = '';
|
|
117
|
-
};
|
|
118
|
-
for (let i = 0; i < command.length; i += 1) {
|
|
119
|
-
const ch = command[i];
|
|
120
|
-
if (escaped) {
|
|
121
|
-
current += ch;
|
|
122
|
-
escaped = false;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (ch === '\\' && quote !== "'") {
|
|
126
|
-
current += ch;
|
|
127
|
-
escaped = true;
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
if (quote !== null) {
|
|
131
|
-
current += ch;
|
|
132
|
-
if (ch === quote)
|
|
133
|
-
quote = null;
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
if (ch === "'" || ch === '"') {
|
|
137
|
-
current += ch;
|
|
138
|
-
quote = ch;
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
if (ch === '\n' || ch === ';') {
|
|
142
|
-
finish();
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
if ((ch === '&' || ch === '|') && command[i + 1] === ch) {
|
|
146
|
-
finish();
|
|
147
|
-
i += 1;
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
if (ch === '|') {
|
|
151
|
-
finish();
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
current += ch;
|
|
155
|
-
}
|
|
156
|
-
finish();
|
|
157
|
-
return segments;
|
|
158
|
-
}
|
|
159
|
-
/** Tokenize one segment into shell words, marking quoted spans opaque. */
|
|
160
|
-
function tokenizeCommandSegment(segment) {
|
|
161
|
-
const tokens = [];
|
|
162
|
-
let text = '';
|
|
163
|
-
let opaque = false;
|
|
164
|
-
let started = false;
|
|
165
|
-
let quote = null;
|
|
166
|
-
let escaped = false;
|
|
167
|
-
const finish = () => {
|
|
168
|
-
if (started)
|
|
169
|
-
tokens.push({ text, opaque });
|
|
170
|
-
text = '';
|
|
171
|
-
opaque = false;
|
|
172
|
-
started = false;
|
|
173
|
-
};
|
|
174
|
-
for (let i = 0; i < segment.length; i += 1) {
|
|
175
|
-
const ch = segment[i];
|
|
176
|
-
if (escaped) {
|
|
177
|
-
text += ch;
|
|
178
|
-
started = true;
|
|
179
|
-
escaped = false;
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
if (ch === '\\' && quote !== "'") {
|
|
183
|
-
escaped = true;
|
|
184
|
-
started = true;
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
if (quote !== null) {
|
|
188
|
-
started = true;
|
|
189
|
-
if (ch === quote) {
|
|
190
|
-
quote = null;
|
|
191
|
-
}
|
|
192
|
-
else {
|
|
193
|
-
text += ch;
|
|
194
|
-
}
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
if (ch === "'" || ch === '"') {
|
|
198
|
-
quote = ch;
|
|
199
|
-
opaque = true;
|
|
200
|
-
started = true;
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
if (/\s/.test(ch)) {
|
|
204
|
-
finish();
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
text += ch;
|
|
208
|
-
started = true;
|
|
209
|
-
}
|
|
210
|
-
if (escaped)
|
|
211
|
-
text += '\\';
|
|
212
|
-
finish();
|
|
213
|
-
return tokens;
|
|
214
|
-
}
|
|
215
|
-
/** Every non-empty command segment as its env-stripped token list: leading
|
|
216
|
-
* `VAR=value` assignments are dropped so `tokens[0]` is the executable. */
|
|
217
|
-
function commandSegments(command) {
|
|
218
|
-
return splitCommandSegments(command)
|
|
219
|
-
.map(tokenizeCommandSegment)
|
|
220
|
-
.map((tokens) => {
|
|
221
|
-
let first = 0;
|
|
222
|
-
while (first < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[first].text))
|
|
223
|
-
first += 1;
|
|
224
|
-
return tokens.slice(first);
|
|
225
|
-
})
|
|
226
|
-
.filter((tokens) => tokens.length > 0);
|
|
227
|
-
}
|
|
228
106
|
function commandGlobPattern(glob) {
|
|
229
107
|
const words = glob.trim().split(/\s+/);
|
|
230
108
|
if (words.length === 1 && words[0] === '')
|
|
@@ -275,7 +153,9 @@ function commandGlobMatches(glob, command) {
|
|
|
275
153
|
const pattern = commandGlobPattern(glob);
|
|
276
154
|
if (pattern === null)
|
|
277
155
|
return false;
|
|
278
|
-
return commandSegments(command)
|
|
156
|
+
return commandSegments(command)
|
|
157
|
+
.filter(({ tokens }) => tokens.length > 0)
|
|
158
|
+
.some(({ tokens }) => segmentMatches(pattern, tokens));
|
|
279
159
|
}
|
|
280
160
|
/** Does a `command` entry fit the executed command string? */
|
|
281
161
|
export function matchesCommandEntry(entry, subject, command) {
|
|
@@ -283,42 +163,6 @@ export function matchesCommandEntry(entry, subject, command) {
|
|
|
283
163
|
return false;
|
|
284
164
|
return entry.match.some((g) => commandGlobMatches(g, command));
|
|
285
165
|
}
|
|
286
|
-
const HEREDOC_OPEN = /<<(-?)\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\2/g;
|
|
287
|
-
/** Remove heredoc BODIES (the stdin data between `<<DELIM` and its closing
|
|
288
|
-
* `DELIM` line) from a command before it is matched. The opening line is kept
|
|
289
|
-
* — `crtr push final <<'EOF'` is still a real `push final` invocation — but the
|
|
290
|
-
* body lines, which are data fed on stdin and never executed as commands, are
|
|
291
|
-
* dropped. Without this a doc-writing command whose body merely MENTIONS a
|
|
292
|
-
* matched invocation would have its whole call held. Post-execution `command`
|
|
293
|
-
* delivery does not need this (a spurious late injection costs a paragraph);
|
|
294
|
-
* a `pre-command` block costs the agent a turn, so it does. */
|
|
295
|
-
export function stripHeredocs(command) {
|
|
296
|
-
const lines = command.split('\n');
|
|
297
|
-
const out = [];
|
|
298
|
-
let i = 0;
|
|
299
|
-
while (i < lines.length) {
|
|
300
|
-
const line = lines[i];
|
|
301
|
-
out.push(line);
|
|
302
|
-
i += 1;
|
|
303
|
-
// Every heredoc opened on this line, left to right; their bodies stack and
|
|
304
|
-
// are consumed in that order.
|
|
305
|
-
const delims = [];
|
|
306
|
-
HEREDOC_OPEN.lastIndex = 0;
|
|
307
|
-
let m;
|
|
308
|
-
while ((m = HEREDOC_OPEN.exec(line)) !== null)
|
|
309
|
-
delims.push({ name: m[3], dash: m[1] === '-' });
|
|
310
|
-
for (const d of delims) {
|
|
311
|
-
while (i < lines.length) {
|
|
312
|
-
// `<<-` strips leading TABS from the body and the closing delimiter.
|
|
313
|
-
const probe = d.dash ? lines[i].replace(/^\t+/, '') : lines[i];
|
|
314
|
-
i += 1;
|
|
315
|
-
if (probe === d.name)
|
|
316
|
-
break; // closing delimiter line — drop it, stop
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
return out.join('\n');
|
|
321
|
-
}
|
|
322
166
|
/** Does a `pre-command` entry fit a command that is ABOUT to run? The same
|
|
323
167
|
* glob machinery `command` uses — one matcher, one semantics — applied to the
|
|
324
168
|
* command with its heredoc bodies stripped. Subshell parentheses, backticks,
|
package/dist/core/worktree.js
CHANGED
|
@@ -954,12 +954,9 @@ export function autoDropCleanManagedWorktreeLocked(nodeId, wt, proof) {
|
|
|
954
954
|
* `open_managed_worktree` guard then still applies unchanged — real
|
|
955
955
|
* commits, real uncommitted changes, and a lost race all require the
|
|
956
956
|
* explicit path. */
|
|
957
|
-
function
|
|
957
|
+
function cleanCurrentCheckoutTip(wt) {
|
|
958
958
|
if (wt.state !== 'open' || !existsSync(wt.path) || isRebaseInProgress(wt.path))
|
|
959
959
|
return null;
|
|
960
|
-
const branch = gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path);
|
|
961
|
-
if (branch.status !== 0 || branch.stdout.trim() !== wt.branch)
|
|
962
|
-
return null;
|
|
963
960
|
const status = gitSync(['status', '--porcelain'], wt.path);
|
|
964
961
|
if (status.status !== 0 || status.stdout.trim() !== '')
|
|
965
962
|
return null;
|
|
@@ -967,7 +964,7 @@ function cleanCurrentBranchTip(wt) {
|
|
|
967
964
|
return head.status === 0 ? head.stdout.trim() : null;
|
|
968
965
|
}
|
|
969
966
|
export async function branchIsPushedAtCurrentTip(wt) {
|
|
970
|
-
const initialTip =
|
|
967
|
+
const initialTip = cleanCurrentCheckoutTip(wt);
|
|
971
968
|
if (initialTip === null)
|
|
972
969
|
return false;
|
|
973
970
|
// A cached origin branch can be stale in either direction. Query origin's
|
|
@@ -989,7 +986,7 @@ export async function branchIsPushedAtCurrentTip(wt) {
|
|
|
989
986
|
return false;
|
|
990
987
|
// The remote proof is useful only if the local checkout is still the exact
|
|
991
988
|
// clean tip it inspected when finalization begins.
|
|
992
|
-
return
|
|
989
|
+
return cleanCurrentCheckoutTip(wt) === initialTip;
|
|
993
990
|
}
|
|
994
991
|
export function autoDropCleanManagedWorktree(nodeId) {
|
|
995
992
|
const wt = openManagedWorktreeForNode(nodeId);
|
|
@@ -29,7 +29,7 @@ import { CrtrClient } from '../../../../api/index.js';
|
|
|
29
29
|
import { registerPush } from '../../../../commands/push.js';
|
|
30
30
|
import { registerNode } from '../../../../commands/node.js';
|
|
31
31
|
import { createNode, getNode } from '../../../../core/canvas/canvas.js';
|
|
32
|
-
import { createManagedWorktree } from '../../../../core/worktree.js';
|
|
32
|
+
import { branchIsPushedAtCurrentTip, createManagedWorktree } from '../../../../core/worktree.js';
|
|
33
33
|
import { readInboxSince } from '../../../../core/feed/inbox.js';
|
|
34
34
|
import { gitSync } from '../../../../core/git.js';
|
|
35
35
|
import { InputError } from '../../../../core/io.js';
|
|
@@ -187,28 +187,38 @@ test('re-homed guard: `crtr push final` on a node with a managed worktree holdin
|
|
|
187
187
|
rmSync(repo, { recursive: true, force: true });
|
|
188
188
|
}
|
|
189
189
|
});
|
|
190
|
-
test('`crtr push final`
|
|
190
|
+
test('`crtr push final` retains a clean managed worktree checked out on a pushed PR branch (#1109, #1061)', async () => {
|
|
191
191
|
const repo = initRepo();
|
|
192
192
|
const remote = mkdtempSync(join(tmpdir(), 'crtr-leafparity-remote-'));
|
|
193
193
|
try {
|
|
194
194
|
git(['init', '--bare'], remote);
|
|
195
195
|
git(['remote', 'add', 'origin', remote], repo);
|
|
196
196
|
git(['push', '-u', 'origin', 'main'], repo);
|
|
197
|
-
const id = 'wt-
|
|
197
|
+
const id = 'wt-pushed-pr-branch-final';
|
|
198
198
|
const wt = createManagedWorktree(repo, id);
|
|
199
199
|
writeFileSync(join(wt.path, 'delivered-via-pr.txt'), 'delivered via a PR branch\n');
|
|
200
200
|
git(['add', '.'], wt.path);
|
|
201
|
-
git(['commit', '-m', 'delivered via
|
|
201
|
+
git(['commit', '-m', 'delivered via PR branch'], wt.path);
|
|
202
202
|
const tip = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
|
|
203
|
-
const
|
|
204
|
-
git(['
|
|
205
|
-
|
|
206
|
-
|
|
203
|
+
const prBranch = 'fix/pushed-pr-worktree-final';
|
|
204
|
+
git(['switch', '-c', prBranch], wt.path);
|
|
205
|
+
git(['push', 'origin', `HEAD:refs/heads/${wt.branch}`], wt.path);
|
|
206
|
+
git(['push', 'origin', `HEAD:refs/heads/${prBranch}`], wt.path);
|
|
207
|
+
assert.equal(gitSync(['rev-parse', `refs/heads/${wt.branch}`], wt.path).stdout.trim(), tip, 'the recorded branch remains at the checkout tip');
|
|
208
|
+
assert.equal(gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path).stdout.trim(), prBranch, 'the checkout uses the PR branch instead of the recorded branch');
|
|
209
|
+
assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${wt.branch}`], wt.path).stdout.split('\t')[0], tip, 'origin carries the recorded branch tip');
|
|
210
|
+
assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${prBranch}`], wt.path).stdout.split('\t')[0], tip, 'origin carries the PR branch tip');
|
|
211
|
+
const untracked = join(wt.path, 'generated.uid');
|
|
212
|
+
writeFileSync(untracked, 'untracked generated file\n');
|
|
213
|
+
assert.equal(await branchIsPushedAtCurrentTip(wt), false, 'an untracked file still rejects the origin-retention proof');
|
|
214
|
+
rmSync(untracked);
|
|
207
215
|
createNode(worktreeNode(id, wt.path, wt));
|
|
208
|
-
const out = await runPushAs(id, 'final', 'delivered through a
|
|
216
|
+
const out = await runPushAs(id, 'final', 'delivered through a PR branch');
|
|
209
217
|
assert.equal(out['worktree_auto_dropped'], undefined, 'the remote-tip allowance retains the open worktree');
|
|
210
|
-
assert.equal(getNode(id)?.managed_worktree?.state, 'open', 'the remote-tip allowance does not close the worktree');
|
|
211
|
-
assert.equal(getNode(id)?.
|
|
218
|
+
assert.equal(getNode(id)?.managed_worktree?.state, 'open', 'the remote-tip allowance does not close the worktree record');
|
|
219
|
+
assert.equal(getNode(id)?.managed_worktree?.branch, wt.branch, 'the retained record keeps its recorded branch');
|
|
220
|
+
assert.equal(gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path).stdout.trim(), prBranch, 'finalization does not change the checked-out PR branch');
|
|
221
|
+
assert.equal(getNode(id)?.status, 'done', 'the node finalizes when both pushed branches have its exact tip');
|
|
212
222
|
}
|
|
213
223
|
finally {
|
|
214
224
|
rmSync(repo, { recursive: true, force: true });
|
|
@@ -100,7 +100,7 @@ async function guardFinalWorktree(id) {
|
|
|
100
100
|
// command-local mapper needed (plan C-5).
|
|
101
101
|
throw new InputError({
|
|
102
102
|
error: 'open_managed_worktree',
|
|
103
|
-
message: 'This node still owns an open managed worktree — `push final`
|
|
103
|
+
message: 'This node still owns an open managed worktree that is neither clean and locally landed nor clean with its exact tip on origin — `push final` cannot finish.',
|
|
104
104
|
next: (() => {
|
|
105
105
|
const wt = getNode(id)?.managed_worktree;
|
|
106
106
|
return wt !== null && wt !== undefined && wt.state === 'open' && !existsSync(wt.path)
|
package/dist/daemon/api/map.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ManagedWorktree, NodeMeta, NodeRow, NodeRuntime, SubscriptionRef } from '../../core/canvas/types.js';
|
|
2
2
|
import type { OutcomeDeliveryRecord } from '../../core/canvas/node-outcome-deliveries.js';
|
|
3
3
|
import type { ReviewComment, ReviewCommentEvent, ReviewErrorCode, ReviewRecord, ReviewResult } from '../../core/review/types.js';
|
|
4
|
-
import type
|
|
4
|
+
import { type Cron, type CronRunRecord } from '../../core/canvas/crons.js';
|
|
5
5
|
import type { ProfileEntry } from '../../core/profiles/manifest.js';
|
|
6
6
|
import type { ReviveResult } from '../../core/runtime/revive.js';
|
|
7
7
|
import type { CloseNodeResult } from '../../core/runtime/close.js';
|
package/dist/daemon/api/map.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
13
13
|
import { outputResultPath } from '../../core/runtime/structured-output.js';
|
|
14
14
|
import { ReviewOperationError as ReviewOperationErrorClass } from '../../core/review/types.js';
|
|
15
|
+
import { cronSinkDisplay } from '../../core/canvas/crons.js';
|
|
15
16
|
import { isProfilePaused, sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
|
|
16
17
|
import { contextDir, inboxPath, nodeDir, nodeMetaPath, reportsDir, reviewResultPath, transcriptPath, viewSocketPath, } from '../../core/canvas/paths.js';
|
|
17
18
|
import { FinalizationError } from '../../core/feed/feed.js';
|
|
@@ -21,7 +22,6 @@ import { CrtrError } from '../../core/errors.js';
|
|
|
21
22
|
import { WorktreeError } from '../../core/worktree.js';
|
|
22
23
|
import { ApiError } from '../../api/errors.js';
|
|
23
24
|
import { ExitCode } from '../../types.js';
|
|
24
|
-
import { sinkDisplay } from '../cron-sink.js';
|
|
25
25
|
import { responsePath } from '../../core/human/convention.js';
|
|
26
26
|
import { ticketDir } from '../../core/human/root.js';
|
|
27
27
|
// Node projections
|
|
@@ -314,7 +314,7 @@ export function toCronDTO(c, lastRun) {
|
|
|
314
314
|
run_timeout_s: c.run_timeout_s,
|
|
315
315
|
overlap: c.overlap,
|
|
316
316
|
on_output: c.on_output,
|
|
317
|
-
sink:
|
|
317
|
+
sink: cronSinkDisplay(c.sink),
|
|
318
318
|
tier: c.tier,
|
|
319
319
|
state: c.state,
|
|
320
320
|
held: c.held,
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Cron } from '../../core/canvas/crons.js';
|
|
2
2
|
export declare function deliverToSink(c: Cron, stdout: string): Promise<string>;
|
|
@@ -13,7 +13,7 @@ import { reviveNode } from '../../core/runtime/revive.js';
|
|
|
13
13
|
import { isBrokerLive } from '../../core/runtime/model-swap.js';
|
|
14
14
|
import { spawnChild } from '../../core/runtime/spawn.js';
|
|
15
15
|
import { cronWakeOrigin } from '../../core/runtime/bearings.js';
|
|
16
|
-
import {
|
|
16
|
+
import { parseCronSink } from '../../core/canvas/crons.js';
|
|
17
17
|
import { resolveLiveProfile, resolveLiveProfileName } from './armed-context.js';
|
|
18
18
|
async function deliverNodeSink(c, target, stdout) {
|
|
19
19
|
const meta = getNode(target);
|
|
@@ -89,7 +89,7 @@ function deliverHumanSink(c, stdout) {
|
|
|
89
89
|
publishPage(ticketDir(id), prepared);
|
|
90
90
|
}
|
|
91
91
|
export async function deliverToSink(c, stdout) {
|
|
92
|
-
const sink =
|
|
92
|
+
const sink = parseCronSink(c.sink);
|
|
93
93
|
if (sink === null)
|
|
94
94
|
return 'no sink armed — nothing delivered';
|
|
95
95
|
if (sink.kind === 'node') {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { newestReportRef, hasActiveLiveSubscription, hasLiveMessageWait, hasLiveSubscriber,
|
|
1
|
+
import { newestReportRef, hasActiveLiveSubscription, hasLiveMessageWait, hasLiveSubscriber, hasPendingCronWake, subscribersOf, } from '../../core/canvas/index.js';
|
|
2
2
|
import { fullName } from '../../core/canvas/labels.js';
|
|
3
3
|
import { countTickets } from '../../core/canvas/attention.js';
|
|
4
4
|
import { hasUnseenWakeMail, readCursor, readInboxSince } from '../../core/feed/inbox.js';
|
|
@@ -12,7 +12,7 @@ import { transition } from '../../core/runtime/lifecycle.js';
|
|
|
12
12
|
export function hasLiveObligation(nodeId) {
|
|
13
13
|
return hasActiveLiveSubscription(nodeId)
|
|
14
14
|
|| hasLiveMessageWait(nodeId)
|
|
15
|
-
||
|
|
15
|
+
|| hasPendingCronWake(nodeId)
|
|
16
16
|
|| countTickets(nodeId) > 0
|
|
17
17
|
|| readOutputRequest(nodeId).state !== 'absent';
|
|
18
18
|
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { createBashToolDefinition, type BashOperations } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
3
|
-
/** Seconds a leading `sleep ...` will block for, or null when the command does
|
|
4
|
-
* not open with a sleep (or its duration isn't statically knowable). */
|
|
5
|
-
export declare function leadingSleepSeconds(command: string): number | null;
|
|
6
3
|
/** The valve's BashOperations backend. Each tool execution closes over its own
|
|
7
4
|
* `takePurpose` and its own preview-result path, so concurrent calls cannot
|
|
8
5
|
* exchange labels or result mirrors. The purpose is consumed even when the
|
|
@@ -31,6 +31,7 @@ import { homedir } from 'node:os';
|
|
|
31
31
|
import { dirname } from 'node:path';
|
|
32
32
|
import { performance } from 'node:perf_hooks';
|
|
33
33
|
import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId, normalizeBashJobPurpose, readBashNiceness, writeJobDeadlineMs } from '../core/bash-jobs.js';
|
|
34
|
+
import { bashCommandRefusal } from '../core/bash-guard.js';
|
|
34
35
|
import { readConfig } from '../core/config.js';
|
|
35
36
|
import { PREVIEW_RESULT_PATH_ENV, previewResultPath } from '../core/preview-result-path.js';
|
|
36
37
|
import { resolveAdmittedHostCommand } from '../core/runtime/spawn-env.js';
|
|
@@ -164,39 +165,6 @@ function readExitCode(path) {
|
|
|
164
165
|
return null;
|
|
165
166
|
}
|
|
166
167
|
}
|
|
167
|
-
// Long-sleep guard. A command that opens with `sleep <long>` is a node burning
|
|
168
|
-
// a live window to wait on a clock — the runtime already has waiting primitives
|
|
169
|
-
// that cost nothing (`crtr cron` for a scheduled wake, `crtr node wait deadline`
|
|
170
|
-
// for an inbox-vs-deadline race). Anything over 10s is refused before it runs.
|
|
171
|
-
const MAX_INLINE_SLEEP_SECONDS = 10;
|
|
172
|
-
const SLEEP_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
173
|
-
/** Seconds a leading `sleep ...` will block for, or null when the command does
|
|
174
|
-
* not open with a sleep (or its duration isn't statically knowable). */
|
|
175
|
-
export function leadingSleepSeconds(command) {
|
|
176
|
-
const match = /^\s*sleep(\s+[^;&|\n]+)/.exec(command);
|
|
177
|
-
if (!match)
|
|
178
|
-
return null;
|
|
179
|
-
// `sleep 1 2m 3` sums its operands.
|
|
180
|
-
let total = 0;
|
|
181
|
-
for (const operand of match[1].trim().split(/\s+/)) {
|
|
182
|
-
const parsed = /^([0-9]*\.?[0-9]+)([smhd]?)$/.exec(operand);
|
|
183
|
-
if (!parsed)
|
|
184
|
-
return null; // variable/expansion/flag — not statically knowable
|
|
185
|
-
total += Number(parsed[1]) * SLEEP_UNIT_SECONDS[parsed[2] || 's'];
|
|
186
|
-
}
|
|
187
|
-
return total;
|
|
188
|
-
}
|
|
189
|
-
function longSleepRefusal(command) {
|
|
190
|
-
const seconds = leadingSleepSeconds(command);
|
|
191
|
-
if (seconds === null || seconds <= MAX_INLINE_SLEEP_SECONDS)
|
|
192
|
-
return null;
|
|
193
|
-
return (`Refused: this command starts by sleeping ${seconds}s, over the ${MAX_INLINE_SLEEP_SECONDS}s inline limit.\n` +
|
|
194
|
-
`Holding a live window on a clock is never how you wait here. Instead:\n` +
|
|
195
|
-
` - schedule the wake and end your turn: crtr cron -h (one-shot at a time, or recurring)\n` +
|
|
196
|
-
` - race an inbox message against a deadline: crtr node wait deadline -h\n` +
|
|
197
|
-
` - waiting on a child, a reply from the user, or any spine event: just stop — the runtime wakes you.\n` +
|
|
198
|
-
`If you truly need a short pause inline, sleep ${MAX_INLINE_SLEEP_SECONDS}s or less.`);
|
|
199
|
-
}
|
|
200
168
|
/** A node can legitimately remove its own merged worktree. Never recreate that
|
|
201
169
|
* path: it would look like a valid checkout but no longer be one. Run later
|
|
202
170
|
* commands from a durable directory instead, and make the changed semantics
|
|
@@ -228,7 +196,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
|
|
|
228
196
|
reject(new Error('aborted'));
|
|
229
197
|
return;
|
|
230
198
|
}
|
|
231
|
-
const refusal =
|
|
199
|
+
const refusal = bashCommandRefusal(command, cwd);
|
|
232
200
|
if (refusal !== null) {
|
|
233
201
|
reject(new Error(refusal));
|
|
234
202
|
return;
|
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.303",
|
|
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.303",
|
|
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.303",
|
|
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.303",
|
|
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.303",
|
|
5220
5220
|
"license": "UNLICENSED",
|
|
5221
5221
|
"dependencies": {
|
|
5222
5222
|
"@north-light/crouter-api": "^0.3.295"
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/** The one decoder for a cron's stored sink JSON.
|
|
2
|
-
*
|
|
3
|
-
* A sink is armed as a CLI spec string (`node:<id>` | `spawn:<kind>` |
|
|
4
|
-
* `human`), validated and encoded to JSON once at arm time
|
|
5
|
-
* (`api/handlers/crons.ts`), and thereafter read back in exactly two places:
|
|
6
|
-
* the runner, which dispatches a delivery on it, and the DTO mapper, which
|
|
7
|
-
* renders it. Both go through here so the stored shape has a single reader.
|
|
8
|
-
*
|
|
9
|
-
* A malformed stored value decodes as no-sink rather than throwing: a cron
|
|
10
|
-
* that cannot deliver must still run, and must still list.
|
|
11
|
-
*/
|
|
12
|
-
export type ParsedSink = {
|
|
13
|
-
kind: 'node';
|
|
14
|
-
node: string;
|
|
15
|
-
} | {
|
|
16
|
-
kind: 'spawn';
|
|
17
|
-
spawn: string;
|
|
18
|
-
} | {
|
|
19
|
-
kind: 'human';
|
|
20
|
-
} | null;
|
|
21
|
-
/** Stored JSON → typed shape. `{}`, malformed JSON, and an unrecognised shape
|
|
22
|
-
* all read as null (no sink). */
|
|
23
|
-
export declare function parseSink(sinkJson: string): ParsedSink;
|
|
24
|
-
/** Stored JSON → the display spec the CLI armed with, or null when the cron
|
|
25
|
-
* never delivers. The exact inverse of the arm-time encoding. */
|
|
26
|
-
export declare function sinkDisplay(sinkJson: string): string | null;
|
package/dist/daemon/cron-sink.js
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/** The one decoder for a cron's stored sink JSON.
|
|
2
|
-
*
|
|
3
|
-
* A sink is armed as a CLI spec string (`node:<id>` | `spawn:<kind>` |
|
|
4
|
-
* `human`), validated and encoded to JSON once at arm time
|
|
5
|
-
* (`api/handlers/crons.ts`), and thereafter read back in exactly two places:
|
|
6
|
-
* the runner, which dispatches a delivery on it, and the DTO mapper, which
|
|
7
|
-
* renders it. Both go through here so the stored shape has a single reader.
|
|
8
|
-
*
|
|
9
|
-
* A malformed stored value decodes as no-sink rather than throwing: a cron
|
|
10
|
-
* that cannot deliver must still run, and must still list.
|
|
11
|
-
*/
|
|
12
|
-
/** Stored JSON → typed shape. `{}`, malformed JSON, and an unrecognised shape
|
|
13
|
-
* all read as null (no sink). */
|
|
14
|
-
export function parseSink(sinkJson) {
|
|
15
|
-
try {
|
|
16
|
-
const s = JSON.parse(sinkJson);
|
|
17
|
-
if (typeof s['node'] === 'string')
|
|
18
|
-
return { kind: 'node', node: s['node'] };
|
|
19
|
-
if (typeof s['spawn'] === 'string')
|
|
20
|
-
return { kind: 'spawn', spawn: s['spawn'] };
|
|
21
|
-
if (s['human'] === true)
|
|
22
|
-
return { kind: 'human' };
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
// malformed stored sink reads as no-sink; callers report it in their own terms
|
|
26
|
-
}
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
/** Stored JSON → the display spec the CLI armed with, or null when the cron
|
|
30
|
-
* never delivers. The exact inverse of the arm-time encoding. */
|
|
31
|
-
export function sinkDisplay(sinkJson) {
|
|
32
|
-
const sink = parseSink(sinkJson);
|
|
33
|
-
if (sink === null)
|
|
34
|
-
return null;
|
|
35
|
-
switch (sink.kind) {
|
|
36
|
-
case 'node':
|
|
37
|
-
return `node:${sink.node}`;
|
|
38
|
-
case 'spawn':
|
|
39
|
-
return `spawn:${sink.spawn}`;
|
|
40
|
-
case 'human':
|
|
41
|
-
return 'human';
|
|
42
|
-
}
|
|
43
|
-
}
|