@executablemd/runtime 0.8.1 → 0.9.1
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/esm/_dnt.polyfills.js +1 -0
- package/esm/_dnt.shims.js +57 -0
- package/esm/agent-session-coordinator.js +91 -0
- package/esm/apis.js +79 -6
- package/esm/deno-agent-session-coordinator.js +228 -0
- package/esm/deno-executable-observer.js +159 -0
- package/esm/executable-observer.js +42 -0
- package/esm/files.js +10 -2
- package/esm/host-files.js +129 -3
- package/esm/launcher.js +322 -0
- package/esm/mod.js +10 -2
- package/esm/test/mod.js +1 -0
- package/esm/test/stubs.js +6 -0
- package/package.json +1 -1
- package/types/_dnt.polyfills.d.ts +6 -0
- package/types/_dnt.shims.d.ts +1 -0
- package/types/agent-session-coordinator.d.ts +93 -0
- package/types/apis.d.ts +76 -7
- package/types/deno-agent-session-coordinator.d.ts +11 -0
- package/types/deno-executable-observer.d.ts +14 -0
- package/types/executable-observer.d.ts +74 -0
- package/types/files.d.ts +17 -3
- package/types/host-files.d.ts +28 -1
- package/types/launcher.d.ts +105 -0
- package/types/mod.d.ts +14 -3
- package/types/test/mod.d.ts +1 -0
- package/types/test/stubs.d.ts +2 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observing which executable build a command actually runs
|
|
3
|
+
* (specs/native-agent-session-launch-spec.md §Executable binding).
|
|
4
|
+
*
|
|
5
|
+
* A provider session whose identity XMD chose itself only means something
|
|
6
|
+
* while the build that established it can be reproduced. Two builds of one
|
|
7
|
+
* provider accept the same session identity and disagree silently about what
|
|
8
|
+
* it names — that is how issue #519's first gate produced a healthy-looking
|
|
9
|
+
* session with no history in it. So before a session crosses an ownership
|
|
10
|
+
* boundary, the exact file about to run is observed, and enough is retained to
|
|
11
|
+
* recognize it later.
|
|
12
|
+
*
|
|
13
|
+
* What is observed is a canonical path, the SHA-256 of that file's bytes, and
|
|
14
|
+
* whatever the file says when asked its version. Only the last two ever become
|
|
15
|
+
* durable: a path stops being true when a build moves, and names host layout
|
|
16
|
+
* besides.
|
|
17
|
+
*
|
|
18
|
+
* This is a plain capability the trusted host builds and hands directly to the
|
|
19
|
+
* provider that needs it. It is deliberately not a contextual Api. Executable
|
|
20
|
+
* validation decides which retained history is accepted, and a decision
|
|
21
|
+
* document middleware could replace is not one — a replaceable resolver could
|
|
22
|
+
* point the observation at a different binary than the one that runs.
|
|
23
|
+
*
|
|
24
|
+
* Provider-specific meaning is not this module's business: which command to
|
|
25
|
+
* run, what a version string looks like, and what to do about a mismatch
|
|
26
|
+
* belong to the adapter that knows the provider.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* An observation failure that names its reason.
|
|
30
|
+
*
|
|
31
|
+
* The reason is the actionable part and the message is diagnostic. Neither is
|
|
32
|
+
* retained: a caller turns this into its own refusal, and the paths involved
|
|
33
|
+
* stay on this side of that boundary.
|
|
34
|
+
*/
|
|
35
|
+
export class ExecutableObservationError extends Error {
|
|
36
|
+
name = "ExecutableObservationError";
|
|
37
|
+
refusal;
|
|
38
|
+
constructor(message, options) {
|
|
39
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
40
|
+
this.refusal = options.refusal;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/esm/files.js
CHANGED
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* replacement — admission, resolution, target classification, parent creation,
|
|
14
14
|
* and commit — rather than a sequence a caller assembles, because assembling it
|
|
15
15
|
* from outside is what would let a path admitted by one provider be used by
|
|
16
|
-
* another. `
|
|
17
|
-
*
|
|
16
|
+
* another. `deleteFile` is the same kind of whole act, and every operation here
|
|
17
|
+
* is mandatory: a provider with nothing to offer for one does not omit it and
|
|
18
|
+
* leave a document reaching the host instead. `API.Fs` remains the low-level
|
|
19
|
+
* host surface a host adapter is built on; it is not this boundary.
|
|
18
20
|
*
|
|
19
21
|
* `checkFilePath` is the one exception, and it is deliberately weak: pure path
|
|
20
22
|
* arithmetic, no filesystem access, and nothing usable comes back — no path, no
|
|
@@ -47,6 +49,7 @@
|
|
|
47
49
|
* and `instanceof` answers false across them, which would turn a provider
|
|
48
50
|
* failure into an unrecognized throw exactly when it matters most.
|
|
49
51
|
*/
|
|
52
|
+
import "./_dnt.polyfills.js";
|
|
50
53
|
import { createApi } from "@effectionx/context-api";
|
|
51
54
|
/** The stable discriminant on ordinary filesystem failure data. */
|
|
52
55
|
export const FILES_ERROR = "executablemd.runtime.files-error/v1";
|
|
@@ -79,6 +82,7 @@ const REASONS = [
|
|
|
79
82
|
const OPERATIONS = [
|
|
80
83
|
"check-file-path",
|
|
81
84
|
"read",
|
|
85
|
+
"delete",
|
|
82
86
|
"glob",
|
|
83
87
|
"temporary-directory",
|
|
84
88
|
];
|
|
@@ -548,6 +552,10 @@ export const Files = createApi("executablemd.runtime.files", {
|
|
|
548
552
|
throw new FilesProviderUnavailableError();
|
|
549
553
|
},
|
|
550
554
|
// deno-lint-ignore require-yield
|
|
555
|
+
*deleteFile(_input) {
|
|
556
|
+
throw new FilesProviderUnavailableError();
|
|
557
|
+
},
|
|
558
|
+
// deno-lint-ignore require-yield
|
|
551
559
|
*globFiles(_input) {
|
|
552
560
|
throw new FilesProviderUnavailableError();
|
|
553
561
|
},
|
package/esm/host-files.js
CHANGED
|
@@ -35,6 +35,25 @@
|
|
|
35
35
|
* has nothing to resolve, and `rename` replaces the link rather than following
|
|
36
36
|
* it wherever it points.
|
|
37
37
|
*
|
|
38
|
+
* ## Deletions
|
|
39
|
+
*
|
|
40
|
+
* A deletion is the mirror image of a write in the one place that matters: the
|
|
41
|
+
* final path segment is deliberately *not* resolved. A write follows an
|
|
42
|
+
* internal link to the file it names, because replacing the link would be the
|
|
43
|
+
* surprising outcome; a deletion removes the link itself, because following it
|
|
44
|
+
* would remove something the document never named — possibly outside the
|
|
45
|
+
* working directory entirely. So resolution stops at the parent prefix, which
|
|
46
|
+
* still catches a directory link leading out, and the authored last segment is
|
|
47
|
+
* put back onto it unresolved.
|
|
48
|
+
*
|
|
49
|
+
* What is then removed is decided by an explicit `lstat` rather than by the
|
|
50
|
+
* platform. A directory is refused whether or not it is empty, and every
|
|
51
|
+
* runtime this ships to reports a nonrecursive removal of one differently.
|
|
52
|
+
* Absence is success on both sides of that classification: a path that already
|
|
53
|
+
* names nothing was already what the document asked for. The removal is the
|
|
54
|
+
* single commit point, and nothing is acquired around it, so cancellation
|
|
55
|
+
* before it changes nothing and there is no cleanup to fail.
|
|
56
|
+
*
|
|
38
57
|
* ## What crosses the boundary
|
|
39
58
|
*
|
|
40
59
|
* Nothing from a caught platform error. An errno code *selects* a
|
|
@@ -47,7 +66,7 @@ import { ensure, Err, Ok, resource, scoped, until } from "effection";
|
|
|
47
66
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
48
67
|
import { randomUUID } from "node:crypto";
|
|
49
68
|
import { mkdtempSync } from "node:fs";
|
|
50
|
-
import { realpath } from "node:fs/promises";
|
|
69
|
+
import { realpath, rmdir } from "node:fs/promises";
|
|
51
70
|
import { tmpdir } from "node:os";
|
|
52
71
|
import { FsApi, rm } from "@effectionx/fs";
|
|
53
72
|
import { API } from "./apis.js";
|
|
@@ -184,6 +203,41 @@ function* destination(input) {
|
|
|
184
203
|
return { reason: reasonOf(error) };
|
|
185
204
|
}
|
|
186
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* The path a removal would act on, with the final segment left alone.
|
|
208
|
+
*
|
|
209
|
+
* Deletion is the one operation whose target must *not* be resolved. A final
|
|
210
|
+
* symbolic link is the entry the document named, and following it would remove
|
|
211
|
+
* something the document never mentioned — possibly outside the working
|
|
212
|
+
* directory entirely. So only the parent prefix is resolved, and the authored
|
|
213
|
+
* last segment is put back onto it unresolved. Resolving the parent is what
|
|
214
|
+
* still catches a directory link leading out; leaving the last segment is what
|
|
215
|
+
* keeps a link a link.
|
|
216
|
+
*
|
|
217
|
+
* The working directory names itself — `.`, or anything that normalizes onto it
|
|
218
|
+
* — and that case cannot go through the parent at all: the parent of `cwd` is
|
|
219
|
+
* outside `cwd`, so the same comparison would report the working directory as
|
|
220
|
+
* an escape. It is classified directly instead, and target classification then
|
|
221
|
+
* refuses it for what it is.
|
|
222
|
+
*/
|
|
223
|
+
function* removalDestination(input) {
|
|
224
|
+
try {
|
|
225
|
+
const base = (yield* API.Fs.operations.realpath(input.cwd)) ?? input.cwd;
|
|
226
|
+
const named = resolve(input.cwd, input.path);
|
|
227
|
+
if (named === resolve(input.cwd)) {
|
|
228
|
+
return { path: base };
|
|
229
|
+
}
|
|
230
|
+
const parent = yield* resolveExisting(dirname(named));
|
|
231
|
+
const path = join(parent, basename(named));
|
|
232
|
+
if (!within(base, path)) {
|
|
233
|
+
return { reason: "resolved-escape" };
|
|
234
|
+
}
|
|
235
|
+
return { path };
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
return { reason: reasonOf(error) };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
187
241
|
function nonWriteFailure(operation, phase, reason) {
|
|
188
242
|
return Err(filesFailure({ operation, phase, reason }));
|
|
189
243
|
}
|
|
@@ -349,6 +403,75 @@ export function hostFilesHandler(options = {}) {
|
|
|
349
403
|
}
|
|
350
404
|
return Ok(fileWriteSuccess("host-committed"));
|
|
351
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Remove one regular file or one final symbolic link.
|
|
408
|
+
*
|
|
409
|
+
* Classification comes first and it is `lstat`, so what is judged is the
|
|
410
|
+
* entry the document named rather than whatever it leads to. A directory is
|
|
411
|
+
* refused whether or not it is empty: `<File.Delete>` names one file, and the
|
|
412
|
+
* platforms disagree about what removing an empty directory nonrecursively
|
|
413
|
+
* even reports — one answers `EISDIR`, another `EFAULT`. Deciding it here is
|
|
414
|
+
* what makes that disagreement invisible.
|
|
415
|
+
*
|
|
416
|
+
* Absence is success, in both places it can appear. A path that already names
|
|
417
|
+
* nothing was already what the document asked for, and a path that stops
|
|
418
|
+
* existing between the classification and the removal is the same answer
|
|
419
|
+
* arrived at by a different route — the removal is this operation's single
|
|
420
|
+
* commit point, and it has no earlier state to restore.
|
|
421
|
+
*/
|
|
422
|
+
function* deleteFile(input) {
|
|
423
|
+
const lexical = inadmissible(input);
|
|
424
|
+
if (lexical !== undefined) {
|
|
425
|
+
return nonWriteFailure("delete", "lexical", lexical);
|
|
426
|
+
}
|
|
427
|
+
const target = yield* removalDestination(input);
|
|
428
|
+
if ("reason" in target) {
|
|
429
|
+
return nonWriteFailure("delete", "resolution", target.reason);
|
|
430
|
+
}
|
|
431
|
+
notify(observe, { operation: "delete", phase: "target" });
|
|
432
|
+
let wasSymbolicLink = false;
|
|
433
|
+
try {
|
|
434
|
+
const info = yield* API.Fs.operations.lstat(target.path);
|
|
435
|
+
if (!info.exists) {
|
|
436
|
+
return Ok(undefined);
|
|
437
|
+
}
|
|
438
|
+
if (info.isDirectory) {
|
|
439
|
+
return nonWriteFailure("delete", "target", "directory");
|
|
440
|
+
}
|
|
441
|
+
if (!info.isFile && !info.isSymbolicLink) {
|
|
442
|
+
return nonWriteFailure("delete", "target", "special-file");
|
|
443
|
+
}
|
|
444
|
+
wasSymbolicLink = info.isSymbolicLink;
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
return nonWriteFailure("delete", "target", reasonOf(error));
|
|
448
|
+
}
|
|
449
|
+
notify(observe, { operation: "delete", phase: "access" });
|
|
450
|
+
try {
|
|
451
|
+
yield* API.Fs.operations.remove(target.path, { recursive: false });
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
const reason = reasonOf(error);
|
|
455
|
+
if (reason === "missing") {
|
|
456
|
+
return Ok(undefined);
|
|
457
|
+
}
|
|
458
|
+
// Windows removes a symbolic link to a directory with RemoveDirectory,
|
|
459
|
+
// and not every runtime's `rm` falls back to it. The entry was classified
|
|
460
|
+
// a link above, so removing it as a directory still removes only the
|
|
461
|
+
// link. A fallback that fails leaves the original refusal in force.
|
|
462
|
+
if (wasSymbolicLink) {
|
|
463
|
+
try {
|
|
464
|
+
yield* until(rmdir(target.path));
|
|
465
|
+
return Ok(undefined);
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
return nonWriteFailure("delete", "access", reason);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return nonWriteFailure("delete", "access", reason);
|
|
472
|
+
}
|
|
473
|
+
return Ok(undefined);
|
|
474
|
+
}
|
|
352
475
|
/**
|
|
353
476
|
* The regular files under `cwd` that `include` selects and `exclude` does not.
|
|
354
477
|
*
|
|
@@ -409,7 +532,7 @@ export function hostFilesHandler(options = {}) {
|
|
|
409
532
|
let created;
|
|
410
533
|
try {
|
|
411
534
|
// oxlint-disable-next-line local/no-sync-filesystem
|
|
412
|
-
created = mkdtempSync(join(tmpdir(), "xmd-tempdir-"));
|
|
535
|
+
created = mkdtempSync(join(options.temporaryRoot ?? tmpdir(), "xmd-tempdir-"));
|
|
413
536
|
}
|
|
414
537
|
catch (error) {
|
|
415
538
|
yield* provide(nonWriteFailure("temporary-directory", "acquire", reasonOf(error)));
|
|
@@ -427,7 +550,7 @@ export function hostFilesHandler(options = {}) {
|
|
|
427
550
|
yield* provide(Ok(canonical));
|
|
428
551
|
});
|
|
429
552
|
}
|
|
430
|
-
return { checkFilePath, readTextFile, writeTextFile, globFiles, temporaryDirectory };
|
|
553
|
+
return { checkFilePath, readTextFile, writeTextFile, deleteFile, globFiles, temporaryDirectory };
|
|
431
554
|
}
|
|
432
555
|
/**
|
|
433
556
|
* Remove a temporary directory as its scope ends.
|
|
@@ -486,6 +609,9 @@ export function useHostFiles(options = {}) {
|
|
|
486
609
|
*writeTextFile([input]) {
|
|
487
610
|
return yield* handler.writeTextFile(input);
|
|
488
611
|
},
|
|
612
|
+
*deleteFile([input]) {
|
|
613
|
+
return yield* handler.deleteFile(input);
|
|
614
|
+
},
|
|
489
615
|
*globFiles([input]) {
|
|
490
616
|
return yield* handler.globFiles(input);
|
|
491
617
|
},
|
package/esm/launcher.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The native launcher — how a host hands one child process the terminal.
|
|
3
|
+
*
|
|
4
|
+
* This is not `exec`. An ordinary command is a captured child: its stdout and
|
|
5
|
+
* stderr are piped so a document can display, capture and journal them, and
|
|
6
|
+
* its exit status is a value the document reads. A native coding-agent UI is
|
|
7
|
+
* the opposite of that. It draws on the terminal, reads the person's
|
|
8
|
+
* keystrokes, and owns the conversation it has with them. None of that may
|
|
9
|
+
* become an XMD process result or a journaled transcript, and a piped child
|
|
10
|
+
* cannot be interactive at all.
|
|
11
|
+
*
|
|
12
|
+
* So a launch asks for three things in order, and each is refusable on its
|
|
13
|
+
* own:
|
|
14
|
+
*
|
|
15
|
+
* 1. `reserve()` takes the one foreground-terminal lease for the run. A host
|
|
16
|
+
* with no terminal refuses here, which is before any session ownership has
|
|
17
|
+
* moved. Two launches cannot hold it at once even when they name different
|
|
18
|
+
* sessions, so native UIs are sequential by construction.
|
|
19
|
+
* 2. `flush()` gives the reader everything the document has produced so far,
|
|
20
|
+
* so the native UI does not open on top of half-written output.
|
|
21
|
+
* 3. `launch()` spawns the child with the terminal inherited, waits for it,
|
|
22
|
+
* and reports its terminal status and nothing else.
|
|
23
|
+
*
|
|
24
|
+
* There is no host default. `xmd run` installs the foreground launcher;
|
|
25
|
+
* a test or embedding host installs a controlled one that needs no terminal.
|
|
26
|
+
* Until one is installed every operation refuses, which is what keeps
|
|
27
|
+
* document help and inspection free of any of this.
|
|
28
|
+
*/
|
|
29
|
+
import { createApi } from "@effectionx/context-api";
|
|
30
|
+
import { ensure, race, resource, scoped, until, withResolvers } from "effection";
|
|
31
|
+
import { spawn as spawnChild } from "node:child_process";
|
|
32
|
+
import process from "node:process";
|
|
33
|
+
export const NATIVE_LAUNCHER_UNAVAILABLE = "no native launcher is installed — this host does not hand a native agent UI " +
|
|
34
|
+
"the terminal. `xmd run` installs one; a test or embedding host installs its own.";
|
|
35
|
+
export class NativeLauncherUnavailableError extends Error {
|
|
36
|
+
name = "NativeLauncherUnavailableError";
|
|
37
|
+
constructor(message = NATIVE_LAUNCHER_UNAVAILABLE) {
|
|
38
|
+
super(message);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export const NativeLauncher = createApi("runtime.nativeLauncher", {
|
|
42
|
+
// deno-lint-ignore require-yield
|
|
43
|
+
*reserve() {
|
|
44
|
+
throw new NativeLauncherUnavailableError();
|
|
45
|
+
},
|
|
46
|
+
// deno-lint-ignore require-yield
|
|
47
|
+
*flush() {
|
|
48
|
+
throw new NativeLauncherUnavailableError();
|
|
49
|
+
},
|
|
50
|
+
// deno-lint-ignore require-yield
|
|
51
|
+
*launch(_request) {
|
|
52
|
+
throw new NativeLauncherUnavailableError();
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
/** Hold the foreground-terminal lease for the calling scope. */
|
|
56
|
+
export function reserveTerminal() {
|
|
57
|
+
return NativeLauncher.operations.reserve();
|
|
58
|
+
}
|
|
59
|
+
/** Give the reader everything the document has produced so far. */
|
|
60
|
+
export function flushOutput() {
|
|
61
|
+
return NativeLauncher.operations.flush();
|
|
62
|
+
}
|
|
63
|
+
/** Run one native UI as a foreground child and report how it ended. */
|
|
64
|
+
export function nativeLaunch(request) {
|
|
65
|
+
return NativeLauncher.operations.launch(request);
|
|
66
|
+
}
|
|
67
|
+
export const NO_TERMINAL = "<Session.Launch> needs a terminal: a native agent UI reads keystrokes and " +
|
|
68
|
+
"draws on the screen, and this invocation has none. Run xmd from a terminal, " +
|
|
69
|
+
"or use a host that installs its own launcher.";
|
|
70
|
+
/**
|
|
71
|
+
* How long an interrupted child is given to leave on its own before the
|
|
72
|
+
* launcher escalates. Cancellation is not permitted to strand a process
|
|
73
|
+
* holding the terminal, so the escalation is bounded rather than patient.
|
|
74
|
+
*/
|
|
75
|
+
const INTERRUPT_GRACE_MS = 2_000;
|
|
76
|
+
/** How often a reap re-checks whether the child is still reachable. */
|
|
77
|
+
const REAP_POLL_MS = 25;
|
|
78
|
+
/** How long an unanswerable kill is given before the child is called gone. */
|
|
79
|
+
const KILL_SETTLE_MS = 500;
|
|
80
|
+
/**
|
|
81
|
+
* Install the launcher that hands a native UI this process's own terminal.
|
|
82
|
+
*
|
|
83
|
+
* XMD stays the parent. It does not replace itself with the child, because a
|
|
84
|
+
* process that has execed away cannot cancel the document, reap the child,
|
|
85
|
+
* own its exit status, or continue after the UI closes.
|
|
86
|
+
*/
|
|
87
|
+
export function* installForegroundLauncher(options = {}) {
|
|
88
|
+
const isTerminal = options.isTerminal ?? (() => process.stdout.isTTY === true);
|
|
89
|
+
const drain = options.drain;
|
|
90
|
+
let held = false;
|
|
91
|
+
yield* NativeLauncher.around({
|
|
92
|
+
reserve() {
|
|
93
|
+
return resource(function* (provide) {
|
|
94
|
+
if (!isTerminal()) {
|
|
95
|
+
throw new NativeLauncherUnavailableError(NO_TERMINAL);
|
|
96
|
+
}
|
|
97
|
+
if (held) {
|
|
98
|
+
throw new Error("another <Session.Launch> already holds this run's terminal — one " +
|
|
99
|
+
"native UI owns the terminal at a time");
|
|
100
|
+
}
|
|
101
|
+
held = true;
|
|
102
|
+
try {
|
|
103
|
+
yield* provide();
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
held = false;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
*flush() {
|
|
111
|
+
if (drain) {
|
|
112
|
+
yield* drain();
|
|
113
|
+
}
|
|
114
|
+
yield* drainStream(process.stdout);
|
|
115
|
+
yield* drainStream(process.stderr);
|
|
116
|
+
},
|
|
117
|
+
*launch([request]) {
|
|
118
|
+
return yield* runForeground(request);
|
|
119
|
+
},
|
|
120
|
+
}, { at: "min" });
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Wait until the host stream has written what was handed to it.
|
|
124
|
+
*
|
|
125
|
+
* A TTY write usually completes synchronously and `write()` reports it did;
|
|
126
|
+
* when it reports back-pressure instead the bytes are still queued, and the
|
|
127
|
+
* child would draw over them.
|
|
128
|
+
*/
|
|
129
|
+
function drainStream(stream) {
|
|
130
|
+
return until(new Promise((resolve) => {
|
|
131
|
+
if (stream.writableLength === 0) {
|
|
132
|
+
resolve();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
stream.write("", () => resolve());
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
function runForeground(request) {
|
|
139
|
+
return scoped(function* () {
|
|
140
|
+
const [command, ...args] = request.command;
|
|
141
|
+
if (command === undefined) {
|
|
142
|
+
throw new Error("native launch: command must not be empty");
|
|
143
|
+
}
|
|
144
|
+
const settled = withResolvers();
|
|
145
|
+
const failed = withResolvers();
|
|
146
|
+
let child;
|
|
147
|
+
// Interrupt, then insist. A cancelled document may not continue — or
|
|
148
|
+
// finish tearing down — while a child still holds the terminal, so this
|
|
149
|
+
// waits for the child to be gone rather than for the signal to have been
|
|
150
|
+
// sent. Registered before the spawn, because a halt between acquiring a
|
|
151
|
+
// process and registering its cleanup leaks the process.
|
|
152
|
+
yield* ensure(() => (child ? until(reap(child)) : undefined));
|
|
153
|
+
// `inherit` is the whole point: the child reads this terminal and draws on
|
|
154
|
+
// it directly, so nothing between it and the person using it can buffer,
|
|
155
|
+
// reorder, capture or journal what passes.
|
|
156
|
+
child = spawnChild(command, args, {
|
|
157
|
+
cwd: request.cwd,
|
|
158
|
+
env: request.env,
|
|
159
|
+
stdio: "inherit",
|
|
160
|
+
});
|
|
161
|
+
child.once("error", (error) => failed.reject(error));
|
|
162
|
+
child.once("exit", (code, signal) => {
|
|
163
|
+
const outcome = {};
|
|
164
|
+
if (code !== null) {
|
|
165
|
+
outcome.exitCode = code;
|
|
166
|
+
}
|
|
167
|
+
if (signal !== null) {
|
|
168
|
+
outcome.signal = signal;
|
|
169
|
+
}
|
|
170
|
+
settled.resolve(outcome);
|
|
171
|
+
});
|
|
172
|
+
return yield* race([settled.operation, failed.operation]);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* End one foreground child and wait for it to be gone.
|
|
177
|
+
*
|
|
178
|
+
* Deliberately one promise rather than an Effection race: this runs while the
|
|
179
|
+
* scope is already being dismantled, and the cheapest correct thing to do
|
|
180
|
+
* there is to wait on the process's own events instead of starting more
|
|
181
|
+
* structured work beside them.
|
|
182
|
+
*
|
|
183
|
+
* A child that ignores the interrupt is killed outright once the grace period
|
|
184
|
+
* is spent — a native UI holding the terminal is not something a cancelled run
|
|
185
|
+
* can afford to wait on indefinitely.
|
|
186
|
+
*/
|
|
187
|
+
function reap(child) {
|
|
188
|
+
const pid = child.pid;
|
|
189
|
+
if (pid === undefined || child.exitCode !== null || child.signalCode !== null) {
|
|
190
|
+
return Promise.resolve();
|
|
191
|
+
}
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
let settled = false;
|
|
194
|
+
// What the escalation established. Bounded settlement is only permitted on
|
|
195
|
+
// the strength of one of these: a fatal signal the kernel accepted, or a
|
|
196
|
+
// process that was already gone. Anything else — a refused delivery, a
|
|
197
|
+
// permission error — leaves a child that may still be running, and
|
|
198
|
+
// reporting that as a successful reap would let the document continue
|
|
199
|
+
// while a native UI still owns the terminal.
|
|
200
|
+
let fatal;
|
|
201
|
+
const done = (outcome) => {
|
|
202
|
+
if (settled) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
settled = true;
|
|
206
|
+
clearInterval(poll);
|
|
207
|
+
clearTimeout(escalation);
|
|
208
|
+
clearTimeout(deadline);
|
|
209
|
+
// Deno's `node:child_process` stops reporting a child's exit once a
|
|
210
|
+
// signal that child ignored has been delivered, and holds the runtime
|
|
211
|
+
// open on the handle it will now never settle. Dropping the reference is
|
|
212
|
+
// what lets a cancelled run finish.
|
|
213
|
+
try {
|
|
214
|
+
child.unref();
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// The runtime already released the handle, which is the state this
|
|
218
|
+
// was asking for.
|
|
219
|
+
}
|
|
220
|
+
if (outcome) {
|
|
221
|
+
reject(outcome);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
resolve();
|
|
225
|
+
};
|
|
226
|
+
child.once("exit", () => done());
|
|
227
|
+
// Reachability rather than the exit event, because that is the fact this
|
|
228
|
+
// has to establish and the event is not dependable across runtimes here.
|
|
229
|
+
const poll = setInterval(() => {
|
|
230
|
+
if (!isReachable(pid)) {
|
|
231
|
+
done();
|
|
232
|
+
}
|
|
233
|
+
}, REAP_POLL_MS);
|
|
234
|
+
const escalation = setTimeout(() => {
|
|
235
|
+
fatal = signal(pid, "SIGKILL");
|
|
236
|
+
}, INTERRUPT_GRACE_MS);
|
|
237
|
+
// A process does not survive SIGKILL, so once the kernel accepted one this
|
|
238
|
+
// stops waiting on a report rather than on the child. What it will not do
|
|
239
|
+
// is call an undelivered signal a termination.
|
|
240
|
+
const deadline = setTimeout(() => {
|
|
241
|
+
if (fatal === "delivered" || fatal === "absent") {
|
|
242
|
+
done();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
done(new Error(`native launch could not establish that process ${pid} stopped: ` +
|
|
246
|
+
`SIGKILL was ${fatal ?? "not delivered"}`));
|
|
247
|
+
}, INTERRUPT_GRACE_MS + KILL_SETTLE_MS);
|
|
248
|
+
signal(pid, "SIGINT");
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Send one signal to the child by pid, and report what that established.
|
|
253
|
+
*
|
|
254
|
+
* Deliberately not `child.kill()`. Deno's `node:child_process` marks a child as
|
|
255
|
+
* killed after the first call and delivers nothing on any later one, so a child
|
|
256
|
+
* that ignores the interrupt could never be escalated through the handle — the
|
|
257
|
+
* run would keep waiting on a native UI still holding the terminal. Addressing
|
|
258
|
+
* the process directly is what makes escalation real.
|
|
259
|
+
*/
|
|
260
|
+
function signal(pid, name) {
|
|
261
|
+
try {
|
|
262
|
+
process.kill(pid, name);
|
|
263
|
+
return "delivered";
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
// Gone between the decision and the delivery is the outcome this was
|
|
267
|
+
// asking for. Anything else is a delivery that did not happen, and is not
|
|
268
|
+
// evidence of termination.
|
|
269
|
+
return isNoSuchProcess(error) ? "absent" : "refused";
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function isNoSuchProcess(error) {
|
|
273
|
+
return (typeof error === "object" &&
|
|
274
|
+
error !== null &&
|
|
275
|
+
"code" in error &&
|
|
276
|
+
error.code === "ESRCH");
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Whether a process still exists. Signal 0 delivers nothing: it asks the
|
|
280
|
+
* kernel whether the pid is reachable, which is the whole question here.
|
|
281
|
+
*/
|
|
282
|
+
function isReachable(pid) {
|
|
283
|
+
try {
|
|
284
|
+
process.kill(pid, 0);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
export function* installControlledLauncher(options = {}) {
|
|
292
|
+
let held = false;
|
|
293
|
+
yield* NativeLauncher.around({
|
|
294
|
+
reserve() {
|
|
295
|
+
return resource(function* (provide) {
|
|
296
|
+
if (held) {
|
|
297
|
+
throw new Error("another <Session.Launch> already holds this run's terminal — one " +
|
|
298
|
+
"native UI owns the terminal at a time");
|
|
299
|
+
}
|
|
300
|
+
held = true;
|
|
301
|
+
options.onReserve?.();
|
|
302
|
+
try {
|
|
303
|
+
yield* provide();
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
held = false;
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
// deno-lint-ignore require-yield
|
|
311
|
+
*flush() {
|
|
312
|
+
options.onFlush?.();
|
|
313
|
+
},
|
|
314
|
+
*launch([request]) {
|
|
315
|
+
options.record?.(request);
|
|
316
|
+
if (options.wait) {
|
|
317
|
+
yield* options.wait(request);
|
|
318
|
+
}
|
|
319
|
+
return options.outcome?.(request) ?? { exitCode: 0 };
|
|
320
|
+
},
|
|
321
|
+
}, { at: "min" });
|
|
322
|
+
}
|
package/esm/mod.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Seven domain APIs:
|
|
9
9
|
* - `API.Process` — subprocess execution (`exec`)
|
|
10
10
|
* - `API.Fs` — the low-level host filesystem (`readTextFile`, `writeTextFile`,
|
|
11
|
-
* `stat`, `glob`, `realpath`, `ensureDir`, `rename`, `remove`)
|
|
11
|
+
* `stat`, `lstat`, `glob`, `realpath`, `ensureDir`, `rename`, `remove`)
|
|
12
12
|
* - `API.Files` — document filesystem access as whole semantic operations,
|
|
13
13
|
* with no host default. `useHostFiles()` installs the host provider.
|
|
14
14
|
* - `API.Fetch` — HTTP requests (`fetch`)
|
|
@@ -16,15 +16,23 @@
|
|
|
16
16
|
* this xmd, and eval-block compilation
|
|
17
17
|
* (`cwd`, `env`, `platform`, `command`, `compile`)
|
|
18
18
|
* - `API.Service` — scoped attached service startup (`startService`)
|
|
19
|
+
* - `NativeLauncher` — handing one native agent UI the foreground terminal
|
|
20
|
+
* (`reserveTerminal`, `flushOutput`, `nativeLaunch`)
|
|
19
21
|
* - `Config` — shared execution config (`timeout`, `timeoutExec`, `timeoutFetch`)
|
|
20
22
|
*
|
|
21
23
|
* See `apis.ts` for architecture rationale.
|
|
22
24
|
* See `@executablemd/runtime/test` for composable test stubs.
|
|
23
25
|
*/
|
|
26
|
+
import "./_dnt.polyfills.js";
|
|
24
27
|
export { API } from "./apis.js";
|
|
25
|
-
export { exec, readTextFile, writeTextFile, stat, glob, realpath, ensureDir, rename, remove, fetch, cwd, env, platform, command, compile, useQuietProcessOutput, } from "./apis.js";
|
|
28
|
+
export { exec, readTextFile, writeTextFile, stat, lstat, glob, realpath, ensureDir, rename, remove, fetch, cwd, env, platform, command, compile, useQuietProcessOutput, } from "./apis.js";
|
|
26
29
|
export { Service, SERVICE_HOSTNAME, SERVICE_READY_PREFIX, ServiceProcessExitBeforeReadyError, ServiceProtocolDuplicateError, ServiceProtocolHostnameMismatchError, ServiceProtocolIncompatibleError, ServiceProtocolMalformedError, ServiceProtocolTokenMismatchError, ServiceProviderError, ServiceStartupTimeoutError, ServiceTeardownError, ServiceUnexpectedExitError, parseServiceReadyRecord, startService, } from "./service.js";
|
|
27
30
|
export { Config, timeout, timeoutExec, timeoutFetch } from "./config.js";
|
|
28
31
|
export { asDuration, durationError, parseDuration } from "./duration.js";
|
|
29
32
|
export { asFilesFatal, FILES_ERROR, FILES_ERROR_MESSAGE, FILES_FATAL, FILES_INVARIANT_MESSAGE, FILES_OPERATION_DENIED_MESSAGE, FILES_PROVIDER_UNAVAILABLE_MESSAGE, FILES_WRITE_SUCCESS, Files, FilesError, FilesInvariantError, FilesOperationDeniedError, FilesProviderUnavailableError, fileWriteFailure, fileWriteSuccess, filesFailure, isFilesFatal, parseFilesPhase, parseFilesReason, parseFileWriteFailure, parseFileWritePhase, parseFileWriteSuccess, parseFilesFailure, parseFilesFatal, } from "./files.js";
|
|
33
|
+
export { flushOutput, installControlledLauncher, installForegroundLauncher, NATIVE_LAUNCHER_UNAVAILABLE, NativeLauncher, NativeLauncherUnavailableError, nativeLaunch, NO_TERMINAL, reserveTerminal, } from "./launcher.js";
|
|
30
34
|
export { hostFilesHandler, useHostFiles } from "./host-files.js";
|
|
35
|
+
export { AgentSessionBusy, agentSessionKeyDigest, AgentSessionRecoveryRequired, parseAgentSessionOwnership, serializeAgentSessionOwnership, } from "./agent-session-coordinator.js";
|
|
36
|
+
export { createDenoAgentSessionCoordinator, hasDenoAgentSessionCoordinator, } from "./deno-agent-session-coordinator.js";
|
|
37
|
+
export { ExecutableObservationError } from "./executable-observer.js";
|
|
38
|
+
export { createDenoExecutableObserver, hasDenoExecutableObserver, } from "./deno-executable-observer.js";
|
package/esm/test/mod.js
CHANGED
package/esm/test/stubs.js
CHANGED
|
@@ -43,6 +43,8 @@ export function* useStubService(endpoint) {
|
|
|
43
43
|
*
|
|
44
44
|
* - `readTextFile` returns content from the `files` map; throws ENOENT for missing keys.
|
|
45
45
|
* - `stat` returns `{ exists: true, isFile: true }` for keys in the map.
|
|
46
|
+
* - `lstat` answers the same, with `isSymbolicLink: false`: an in-memory map
|
|
47
|
+
* holds file content, so nothing in it is a link to somewhere else.
|
|
46
48
|
* - `glob` throws (not stubbed). Install `API.Fs.around()` directly if needed.
|
|
47
49
|
* - the writing half — `writeTextFile`, `ensureDir`, `rename`, `remove`, and
|
|
48
50
|
* `realpath` — is not stubbed and reaches the real filesystem. A test that
|
|
@@ -65,6 +67,10 @@ export function* useStubFs(files) {
|
|
|
65
67
|
const exists = path in files;
|
|
66
68
|
return { exists, isFile: exists, isDirectory: false };
|
|
67
69
|
},
|
|
70
|
+
*lstat([path], _next) {
|
|
71
|
+
const exists = path in files;
|
|
72
|
+
return { exists, isFile: exists, isDirectory: false, isSymbolicLink: false };
|
|
73
|
+
},
|
|
68
74
|
*glob(_args, _next) {
|
|
69
75
|
throw new Error("glob not stubbed");
|
|
70
76
|
},
|
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const dntGlobalThis: Omit<typeof globalThis, never>;
|