@abelxiaoxing/cadence 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -41
- package/docs/reuse-and-distribution.md +2 -1
- package/docs/service-management.md +50 -3
- package/package.json +9 -10
- package/src/install-config.mjs +25 -0
- package/src/native-client.ts +31 -3
- package/src/operator-cli.mjs +52 -16
- package/src/service/coordination.ts +35 -16
- package/src/service/private-files.ts +128 -23
- package/src/service-cli.mjs +734 -144
- package/src/service-host.mjs +839 -313
- package/src/task-shell.ts +16 -6
- package/src/task-store.ts +104 -25
- package/src/worker-tools.mjs +42 -17
- package/web/dist/assets/cadence.js +1 -1
- package/web/dist/manifest.json +3 -3
package/src/task-shell.ts
CHANGED
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
truncateToWidth,
|
|
10
10
|
wrapTextWithAnsi,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
autostartHint,
|
|
14
|
+
MANAGEMENT_GUIDANCE,
|
|
15
|
+
NativeConnection,
|
|
16
|
+
} from "./native-client.ts";
|
|
13
17
|
import {
|
|
14
18
|
NativeHistory,
|
|
15
19
|
type NativeViews,
|
|
@@ -180,14 +184,20 @@ export function registerTaskShell(pi: ExtensionAPI): void {
|
|
|
180
184
|
const clean = diagnosticText(text, Number.MAX_SAFE_INTEGER);
|
|
181
185
|
await ctx.ui.custom<void>((tui, _theme, _keys, done) => {
|
|
182
186
|
let offset = 0,
|
|
183
|
-
rows: string[] = []
|
|
187
|
+
rows: string[] = [],
|
|
188
|
+
wrapped: number | undefined;
|
|
184
189
|
const close = () => done();
|
|
185
190
|
dismiss = close;
|
|
186
191
|
return {
|
|
187
192
|
render(width) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
193
|
+
// Wrap the whole text once per width; paging only moves the window.
|
|
194
|
+
const columns = Math.max(1, width);
|
|
195
|
+
if (columns !== wrapped) {
|
|
196
|
+
rows = clean
|
|
197
|
+
.split("\n")
|
|
198
|
+
.flatMap((line) => wrapTextWithAnsi(line, columns));
|
|
199
|
+
wrapped = columns;
|
|
200
|
+
}
|
|
191
201
|
const height = Math.max(1, Math.min(24, tui.terminal.rows - 4));
|
|
192
202
|
offset = Math.max(
|
|
193
203
|
0,
|
|
@@ -1019,7 +1029,7 @@ export function registerTaskShell(pi: ExtensionAPI): void {
|
|
|
1019
1029
|
}
|
|
1020
1030
|
} catch (error) {
|
|
1021
1031
|
ctx.ui.notify(
|
|
1022
|
-
`${error instanceof ContractError ? error.code : "unavailable"}: Cadence refused or observation unavailable. No automatic action replay.\n${attached ? "Reopen /cadence for a fresh observation and confirmation. Accepted work may continue. For quarantined/unavailable ownership use cadence-service diagnose --workspace <path>; no locks were cleared." : MANAGEMENT_GUIDANCE}`,
|
|
1032
|
+
`${error instanceof ContractError ? error.code : "unavailable"}: Cadence refused or observation unavailable. No automatic action replay.\n${attached ? "Reopen /cadence for a fresh observation and confirmation. Accepted work may continue. For quarantined/unavailable ownership use cadence-service diagnose --workspace <path>; no locks were cleared." : `${MANAGEMENT_GUIDANCE}${autostartHint()}`}`,
|
|
1023
1033
|
"error",
|
|
1024
1034
|
);
|
|
1025
1035
|
} finally {
|
package/src/task-store.ts
CHANGED
|
@@ -75,16 +75,11 @@ export function safeDirectory(
|
|
|
75
75
|
at = join(at, part);
|
|
76
76
|
let existing: Stats | undefined;
|
|
77
77
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
// One no-follow observation per component: a link is never reported as
|
|
79
|
+
// a directory, and the inspection sees this same, latest observation.
|
|
80
80
|
const checked = lstatSync(at);
|
|
81
|
-
if (
|
|
82
|
-
checked.isSymbolicLink() ||
|
|
83
|
-
(inspectExisting && !checked.isDirectory())
|
|
84
|
-
)
|
|
81
|
+
if (!checked.isDirectory() || checked.isSymbolicLink())
|
|
85
82
|
throw new Error("Unsafe directory");
|
|
86
|
-
// Inspect the latest observation, not an earlier stat whose mode/uid
|
|
87
|
-
// may already have changed. No extra IO or cross-call authority cache.
|
|
88
83
|
existing = checked;
|
|
89
84
|
} catch (error) {
|
|
90
85
|
if (!absent(error)) throw error;
|
|
@@ -95,7 +90,8 @@ export function safeDirectory(
|
|
|
95
90
|
if (!object(mkdirError) || mkdirError.code !== "EEXIST")
|
|
96
91
|
throw mkdirError;
|
|
97
92
|
}
|
|
98
|
-
|
|
93
|
+
const created = lstatSync(at);
|
|
94
|
+
if (!created.isDirectory() || created.isSymbolicLink())
|
|
99
95
|
throw new Error("Unsafe directory");
|
|
100
96
|
}
|
|
101
97
|
// Outside the ENOENT handler: an inspection refusal is never absence and
|
|
@@ -104,12 +100,52 @@ export function safeDirectory(
|
|
|
104
100
|
}
|
|
105
101
|
return true;
|
|
106
102
|
}
|
|
107
|
-
|
|
103
|
+
const transientWindows = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
104
|
+
/** Windows refuses to replace, remove or recreate a path while another process
|
|
105
|
+
* holds it (a polling reader, antivirus, a delete still pending), which POSIX
|
|
106
|
+
* never does. Retry the same operation briefly; `recheck` reasserts the
|
|
107
|
+
* caller's expected bytes before every retry, so a retry never overwrites a
|
|
108
|
+
* change. Other errors, including EEXIST and ENOENT, keep their meaning. */
|
|
109
|
+
export function retryWindowsFileOperation<T>(
|
|
110
|
+
operation: () => T,
|
|
111
|
+
recheck?: () => void,
|
|
112
|
+
): T {
|
|
113
|
+
if (process.platform !== "win32") return operation();
|
|
114
|
+
const deadline = performance.now() + 2000;
|
|
115
|
+
for (let delay = 2; ; delay = Math.min(delay * 2, 50)) {
|
|
116
|
+
try {
|
|
117
|
+
return operation();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (
|
|
120
|
+
!object(error) ||
|
|
121
|
+
!transientWindows.has(String(error.code)) ||
|
|
122
|
+
performance.now() >= deadline
|
|
123
|
+
)
|
|
124
|
+
throw error;
|
|
125
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
|
|
126
|
+
recheck?.();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** `packaged` reads the installed package's own files (web assets, Agent
|
|
131
|
+
* profiles). A package manager may hard-link them into its store (Bun's default
|
|
132
|
+
* backend), so a second link is not an alias of workspace or task state; their
|
|
133
|
+
* callers bind content instead. Every other read stays single-link. */
|
|
134
|
+
export function readBounded(
|
|
135
|
+
path: string,
|
|
136
|
+
max: number,
|
|
137
|
+
packaged = false,
|
|
138
|
+
): Buffer | undefined {
|
|
108
139
|
if (!safeDirectory(dirname(path))) return undefined;
|
|
109
140
|
let fd: number;
|
|
110
141
|
try {
|
|
111
142
|
const st = lstatSync(path);
|
|
112
|
-
if (
|
|
143
|
+
if (
|
|
144
|
+
!st.isFile() ||
|
|
145
|
+
st.isSymbolicLink() ||
|
|
146
|
+
(!packaged && st.nlink !== 1) ||
|
|
147
|
+
st.size > max
|
|
148
|
+
)
|
|
113
149
|
throw new Error("Unsafe or oversized file");
|
|
114
150
|
fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
115
151
|
} catch (error) {
|
|
@@ -118,7 +154,7 @@ export function readBounded(path: string, max: number): Buffer | undefined {
|
|
|
118
154
|
}
|
|
119
155
|
try {
|
|
120
156
|
const st = fstatSync(fd);
|
|
121
|
-
if (!st.isFile() || st.nlink !== 1 || st.size > max)
|
|
157
|
+
if (!st.isFile() || (!packaged && st.nlink !== 1) || st.size > max)
|
|
122
158
|
throw new Error("Unsafe or oversized file");
|
|
123
159
|
// Read at most max+1 even if another editor grows the file after stat.
|
|
124
160
|
const buffer = Buffer.alloc(max + 1);
|
|
@@ -215,6 +251,42 @@ function canonicalAmbientRoot(path: string): string {
|
|
|
215
251
|
return join(canonicalAmbientRoot(parent), full.slice(parent.length));
|
|
216
252
|
}
|
|
217
253
|
}
|
|
254
|
+
function privateRootFor(canonicalRoot: string, stateHome: string): string {
|
|
255
|
+
return join(
|
|
256
|
+
canonicalAmbientRoot(stateHome),
|
|
257
|
+
"cadence-assistant-v1",
|
|
258
|
+
createHash("sha256").update(canonicalRoot).digest("hex"),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
function contains(root: string, path: string): boolean {
|
|
262
|
+
const within = relative(root, resolve(path));
|
|
263
|
+
return (
|
|
264
|
+
!within ||
|
|
265
|
+
(!within.startsWith(`..${sep}`) && within !== ".." && !isAbsolute(within))
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
/** Private history, locks and access material must stay outside the working
|
|
269
|
+
* copy, or change observation, check freshness and review would see them. */
|
|
270
|
+
export function stateHomeInside(
|
|
271
|
+
root: string,
|
|
272
|
+
stateHome = defaultStateHome(),
|
|
273
|
+
): boolean {
|
|
274
|
+
const canonical = realpathSync(root);
|
|
275
|
+
return contains(canonical, privateRootFor(canonical, stateHome));
|
|
276
|
+
}
|
|
277
|
+
export const LANDING_WORKSPACE = "cadence";
|
|
278
|
+
/** A directory that contains the private state home (the home directory and
|
|
279
|
+
* its ancestors) can never be a workspace; use ~/cadence instead of refusing.
|
|
280
|
+
* Observation only: the caller decides whether the landing may be created. */
|
|
281
|
+
export function effectiveWorkspace(
|
|
282
|
+
requested: string,
|
|
283
|
+
stateHome?: string,
|
|
284
|
+
home = homedir(),
|
|
285
|
+
): { workspace: string; redirected: boolean } {
|
|
286
|
+
if (!stateHomeInside(resolveWorkspace(requested).root, stateHome))
|
|
287
|
+
return { workspace: requested, redirected: false };
|
|
288
|
+
return { workspace: join(home, LANDING_WORKSPACE), redirected: true };
|
|
289
|
+
}
|
|
218
290
|
export interface CheckoutLock {
|
|
219
291
|
schema: "cadence-assistant-lock/1";
|
|
220
292
|
token: string;
|
|
@@ -230,16 +302,8 @@ export class TaskStore {
|
|
|
230
302
|
constructor(root: string, stateHome = defaultStateHome()) {
|
|
231
303
|
this.root = realpathSync(root);
|
|
232
304
|
this.tasks = join(this.root, ".cadence", "assistant", "tasks");
|
|
233
|
-
this.privateRoot =
|
|
234
|
-
|
|
235
|
-
"cadence-assistant-v1",
|
|
236
|
-
createHash("sha256").update(this.root).digest("hex"),
|
|
237
|
-
);
|
|
238
|
-
const within = relative(this.root, resolve(this.privateRoot));
|
|
239
|
-
if (
|
|
240
|
-
!within ||
|
|
241
|
-
(!within.startsWith(`..${sep}`) && within !== ".." && !isAbsolute(within))
|
|
242
|
-
)
|
|
305
|
+
this.privateRoot = privateRootFor(this.root, stateHome);
|
|
306
|
+
if (contains(this.root, this.privateRoot))
|
|
243
307
|
throw new Error("Private state home must be outside the working copy");
|
|
244
308
|
}
|
|
245
309
|
get lockPath(): string {
|
|
@@ -310,7 +374,9 @@ export class TaskStore {
|
|
|
310
374
|
};
|
|
311
375
|
let fd: number;
|
|
312
376
|
try {
|
|
313
|
-
fd =
|
|
377
|
+
fd = retryWindowsFileOperation(() =>
|
|
378
|
+
openSync(this.lockPath, "wx", 0o600),
|
|
379
|
+
);
|
|
314
380
|
} catch {
|
|
315
381
|
throw new Error(
|
|
316
382
|
"Checkout busy or unresolved lock; inspect and explicitly recover only a dead owner",
|
|
@@ -332,7 +398,10 @@ export class TaskStore {
|
|
|
332
398
|
if (lock.pid !== process.pid || lock.host !== hostname())
|
|
333
399
|
throw new Error("Only the owning process may release a lock");
|
|
334
400
|
this.assertOwner(lock);
|
|
335
|
-
|
|
401
|
+
retryWindowsFileOperation(
|
|
402
|
+
() => unlinkSync(this.lockPath),
|
|
403
|
+
() => this.assertOwner(lock),
|
|
404
|
+
);
|
|
336
405
|
}
|
|
337
406
|
recover(expected: CheckoutLock, acknowledgeEscapedProcesses: boolean): void {
|
|
338
407
|
if (!acknowledgeEscapedProcesses || expected.host !== hostname())
|
|
@@ -426,7 +495,17 @@ export class TaskStore {
|
|
|
426
495
|
throw new Error(
|
|
427
496
|
"Task content changed; replacement preserved without overwrite",
|
|
428
497
|
);
|
|
429
|
-
|
|
498
|
+
retryWindowsFileOperation(
|
|
499
|
+
() => renameSync(temporary, join(dir, "task.json")),
|
|
500
|
+
() => {
|
|
501
|
+
this.assertOwner(lock);
|
|
502
|
+
const now = readBounded(join(dir, "task.json"), MAX_RECORD_BYTES);
|
|
503
|
+
if ((now ? contentIdentity(now) : undefined) !== expectedIdentity)
|
|
504
|
+
throw new Error(
|
|
505
|
+
"Task content changed; replacement preserved without overwrite",
|
|
506
|
+
);
|
|
507
|
+
},
|
|
508
|
+
);
|
|
430
509
|
return next;
|
|
431
510
|
} finally {
|
|
432
511
|
if (!held) this.release(lock);
|
package/src/worker-tools.mjs
CHANGED
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
TextSanitizer,
|
|
9
9
|
} from "./worker-redaction.mjs";
|
|
10
10
|
|
|
11
|
+
/** Matches the stock bash renderer's update throttle. */
|
|
12
|
+
const UPDATE_INTERVAL_MS = 100;
|
|
13
|
+
|
|
11
14
|
/** No stock bash definition/OutputAccumulator: raw output is never spilled. */
|
|
12
15
|
export function captureBash(
|
|
13
16
|
cwd,
|
|
@@ -57,6 +60,39 @@ export function captureBash(
|
|
|
57
60
|
prefix.grow(Buffer.byteLength(text));
|
|
58
61
|
output += text;
|
|
59
62
|
};
|
|
63
|
+
const refuse = () => {
|
|
64
|
+
failed = true;
|
|
65
|
+
local.abort();
|
|
66
|
+
fail();
|
|
67
|
+
};
|
|
68
|
+
// Previews only: the final result carries every byte. Windows pipes
|
|
69
|
+
// deliver chatty output as thousands of small chunks, and each update is
|
|
70
|
+
// a durable capture detail, so throttle like the stock bash renderer.
|
|
71
|
+
let lastUpdate = Number.NEGATIVE_INFINITY;
|
|
72
|
+
let pending;
|
|
73
|
+
const publish = () => {
|
|
74
|
+
pending = undefined;
|
|
75
|
+
if (failed) return;
|
|
76
|
+
lastUpdate = performance.now();
|
|
77
|
+
try {
|
|
78
|
+
let tail = output.slice(-4096);
|
|
79
|
+
if (/^[\uDC00-\uDFFF]/.test(tail)) tail = tail.slice(1);
|
|
80
|
+
onUpdate?.({
|
|
81
|
+
content: [{ type: "text", text: tail }],
|
|
82
|
+
details:
|
|
83
|
+
output.length > tail.length
|
|
84
|
+
? {
|
|
85
|
+
truncation: {
|
|
86
|
+
observedBytes: Buffer.byteLength(output),
|
|
87
|
+
updatePreview: true,
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
: {},
|
|
91
|
+
});
|
|
92
|
+
} catch {
|
|
93
|
+
refuse();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
60
96
|
try {
|
|
61
97
|
const result = await createLocalBashOperations().exec(command, cwd, {
|
|
62
98
|
signal: local.signal,
|
|
@@ -74,24 +110,11 @@ export function captureBash(
|
|
|
74
110
|
)
|
|
75
111
|
throw new ContractError("oversize");
|
|
76
112
|
append(sanitizer.write(decoder.decode(data, { stream: true })));
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
content: [{ type: "text", text: tail }],
|
|
81
|
-
details:
|
|
82
|
-
output.length > tail.length
|
|
83
|
-
? {
|
|
84
|
-
truncation: {
|
|
85
|
-
observedBytes: Buffer.byteLength(output),
|
|
86
|
-
updatePreview: true,
|
|
87
|
-
},
|
|
88
|
-
}
|
|
89
|
-
: {},
|
|
90
|
-
});
|
|
113
|
+
const since = performance.now() - lastUpdate;
|
|
114
|
+
if (since >= UPDATE_INTERVAL_MS) publish();
|
|
115
|
+
else pending ??= setTimeout(publish, UPDATE_INTERVAL_MS - since);
|
|
91
116
|
} catch {
|
|
92
|
-
|
|
93
|
-
local.abort();
|
|
94
|
-
fail();
|
|
117
|
+
refuse();
|
|
95
118
|
} finally {
|
|
96
119
|
raw?.release();
|
|
97
120
|
}
|
|
@@ -117,6 +140,8 @@ export function captureBash(
|
|
|
117
140
|
details: {},
|
|
118
141
|
};
|
|
119
142
|
} finally {
|
|
143
|
+
// No preview may follow the result.
|
|
144
|
+
clearTimeout(pending);
|
|
120
145
|
prefix.release();
|
|
121
146
|
signal?.removeEventListener("abort", abort);
|
|
122
147
|
}
|