@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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const dntGlobals = {};
|
|
2
|
+
export const dntGlobalThis = createMergeProxy(globalThis, dntGlobals);
|
|
3
|
+
function createMergeProxy(baseObj, extObj) {
|
|
4
|
+
return new Proxy(baseObj, {
|
|
5
|
+
get(_target, prop, _receiver) {
|
|
6
|
+
if (prop in extObj) {
|
|
7
|
+
return extObj[prop];
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
return baseObj[prop];
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
set(_target, prop, value) {
|
|
14
|
+
if (prop in extObj) {
|
|
15
|
+
delete extObj[prop];
|
|
16
|
+
}
|
|
17
|
+
baseObj[prop] = value;
|
|
18
|
+
return true;
|
|
19
|
+
},
|
|
20
|
+
deleteProperty(_target, prop) {
|
|
21
|
+
let success = false;
|
|
22
|
+
if (prop in extObj) {
|
|
23
|
+
delete extObj[prop];
|
|
24
|
+
success = true;
|
|
25
|
+
}
|
|
26
|
+
if (prop in baseObj) {
|
|
27
|
+
delete baseObj[prop];
|
|
28
|
+
success = true;
|
|
29
|
+
}
|
|
30
|
+
return success;
|
|
31
|
+
},
|
|
32
|
+
ownKeys(_target) {
|
|
33
|
+
const baseKeys = Reflect.ownKeys(baseObj);
|
|
34
|
+
const extKeys = Reflect.ownKeys(extObj);
|
|
35
|
+
const extKeysSet = new Set(extKeys);
|
|
36
|
+
return [...baseKeys.filter((k) => !extKeysSet.has(k)), ...extKeys];
|
|
37
|
+
},
|
|
38
|
+
defineProperty(_target, prop, desc) {
|
|
39
|
+
if (prop in extObj) {
|
|
40
|
+
delete extObj[prop];
|
|
41
|
+
}
|
|
42
|
+
Reflect.defineProperty(baseObj, prop, desc);
|
|
43
|
+
return true;
|
|
44
|
+
},
|
|
45
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
46
|
+
if (prop in extObj) {
|
|
47
|
+
return Reflect.getOwnPropertyDescriptor(extObj, prop);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
return Reflect.getOwnPropertyDescriptor(baseObj, prop);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
has(_target, prop) {
|
|
54
|
+
return prop in extObj || prop in baseObj;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exclusive ownership of one logical agent session
|
|
3
|
+
* (specs/native-agent-session-launch-spec.md §Ownership and concurrency).
|
|
4
|
+
*
|
|
5
|
+
* An advertised provider-returned session can be handed to a native UI, and a
|
|
6
|
+
* native UI is a live owner nothing on the XMD side can observe. So every
|
|
7
|
+
* operation that could act on such a session — establishing it, running a turn,
|
|
8
|
+
* launching into it — enters here first, and a host that cannot answer the
|
|
9
|
+
* ownership question refuses rather than guessing.
|
|
10
|
+
*
|
|
11
|
+
* This is a plain capability, injected directly by the host into the provider
|
|
12
|
+
* that needs it. It is deliberately not a contextual Api: ownership is a
|
|
13
|
+
* security decision, and a decision document middleware can replace is not one.
|
|
14
|
+
*
|
|
15
|
+
* Acquisition never waits. A native UI may stay open for hours, and a caller
|
|
16
|
+
* that queued would hold the reader's terminal while offering no way to reach
|
|
17
|
+
* the owner it waits for.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
/** Another live owner holds this session right now. */
|
|
21
|
+
export class AgentSessionBusy extends Error {
|
|
22
|
+
name = "AgentSessionBusy";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The last owner never proved it stopped.
|
|
26
|
+
*
|
|
27
|
+
* A crash releases the kernel lock but not this: nothing observable afterwards
|
|
28
|
+
* distinguishes a session whose owner died mid-turn from one it left cleanly,
|
|
29
|
+
* so the conservative answer stands until someone recovers it deliberately.
|
|
30
|
+
*/
|
|
31
|
+
export class AgentSessionRecoveryRequired extends Error {
|
|
32
|
+
name = "AgentSessionRecoveryRequired";
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The digest that names one session's sidecar and ownership record.
|
|
36
|
+
*
|
|
37
|
+
* Canonical, so every process derives the same name, and a digest, so the
|
|
38
|
+
* coordination namespace holds no agent name, session name, path or authored
|
|
39
|
+
* value.
|
|
40
|
+
*/
|
|
41
|
+
export function agentSessionKeyDigest(key) {
|
|
42
|
+
return createHash("sha256")
|
|
43
|
+
.update(JSON.stringify([key.provider, key.agent, key.sessionKey]), "utf8")
|
|
44
|
+
.digest("hex");
|
|
45
|
+
}
|
|
46
|
+
const OWNER_KINDS = ["session", "prompt", "native-launch"];
|
|
47
|
+
function isRecord(value) {
|
|
48
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Read an ownership record strictly.
|
|
52
|
+
*
|
|
53
|
+
* An unknown schema, a missing field, or a member this build cannot account for
|
|
54
|
+
* describes state it must not act on — so it is refused rather than read
|
|
55
|
+
* partially, and never repaired.
|
|
56
|
+
*/
|
|
57
|
+
export function parseAgentSessionOwnership(value) {
|
|
58
|
+
if (!isRecord(value) || value.schema !== "agent-session-ownership.v1") {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const allowed = ["schema", "keyDigest", "state", "ownerKind", "operationId"];
|
|
62
|
+
for (const member of Object.keys(value)) {
|
|
63
|
+
if (!allowed.includes(member)) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const { keyDigest, state, operationId } = value;
|
|
68
|
+
if (typeof keyDigest !== "string" || !/^[0-9a-f]{64}$/.test(keyDigest)) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
if (state !== "active" && state !== "idle") {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
if (typeof operationId !== "string" || operationId.length === 0) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const ownerKind = OWNER_KINDS.find((kind) => kind === value.ownerKind);
|
|
78
|
+
if (!ownerKind) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
return { schema: "agent-session-ownership.v1", keyDigest, state, ownerKind, operationId };
|
|
82
|
+
}
|
|
83
|
+
export function serializeAgentSessionOwnership(record) {
|
|
84
|
+
return `${JSON.stringify({
|
|
85
|
+
schema: record.schema,
|
|
86
|
+
keyDigest: record.keyDigest,
|
|
87
|
+
state: record.state,
|
|
88
|
+
ownerKind: record.ownerKind,
|
|
89
|
+
operationId: record.operationId,
|
|
90
|
+
}, null, 2)}\n`;
|
|
91
|
+
}
|
package/esm/apis.js
CHANGED
|
@@ -75,12 +75,39 @@ import { join } from "node:path";
|
|
|
75
75
|
import process from "node:process";
|
|
76
76
|
import { realpath as fsRealpath, rename as fsRename } from "node:fs/promises";
|
|
77
77
|
import { fetch as effectionFetch } from "@effectionx/fetch";
|
|
78
|
-
import { ensureDir as fsEnsureDir, FsApi, globToRegExp, readTextFile as fsReadTextFile, rm as fsRm, stat as fsStat, writeTextFile as fsWriteTextFile, } from "@effectionx/fs";
|
|
78
|
+
import { ensureDir as fsEnsureDir, FsApi, globToRegExp, lstat as fsLstat, readTextFile as fsReadTextFile, rm as fsRm, stat as fsStat, writeTextFile as fsWriteTextFile, } from "@effectionx/fs";
|
|
79
79
|
import { exec as processExec, Stdio } from "@effectionx/process";
|
|
80
80
|
import { race, scoped, sleep, until } from "effection";
|
|
81
81
|
import { timeoutFetch as contextualFetchTimeout } from "./config.js";
|
|
82
82
|
import { Files } from "./files.js";
|
|
83
83
|
import { Service } from "./service.js";
|
|
84
|
+
/**
|
|
85
|
+
* The headers of one response, detached from it.
|
|
86
|
+
*
|
|
87
|
+
* Taken while the live response is still in scope, so what a caller reads
|
|
88
|
+
* afterwards cannot depend on a response that has been disposed, and mutating
|
|
89
|
+
* the host's own `Headers` cannot change what was read. `get()` keeps the
|
|
90
|
+
* case-insensitive lookup callers already have, and joins repeated names in
|
|
91
|
+
* report order — the platform combines them before this sees them, so on the
|
|
92
|
+
* default adapter there is one entry per name to begin with.
|
|
93
|
+
*/
|
|
94
|
+
function headerPair([name, value]) {
|
|
95
|
+
return [name, value];
|
|
96
|
+
}
|
|
97
|
+
function detachHeaders(headers) {
|
|
98
|
+
const entries = [];
|
|
99
|
+
for (const entry of headers) {
|
|
100
|
+
entries.push(headerPair(entry));
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
get(key) {
|
|
104
|
+
const wanted = key.toLowerCase();
|
|
105
|
+
const found = entries.filter(([name]) => name.toLowerCase() === wanted);
|
|
106
|
+
return found.length === 0 ? null : found.map(([, value]) => value).join(", ");
|
|
107
|
+
},
|
|
108
|
+
entries: () => entries.map(headerPair),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
84
111
|
/**
|
|
85
112
|
* The `errno` string a failed filesystem call carries, when it carries one.
|
|
86
113
|
*
|
|
@@ -311,6 +338,23 @@ export const API = {
|
|
|
311
338
|
throw err;
|
|
312
339
|
}
|
|
313
340
|
},
|
|
341
|
+
*lstat(path) {
|
|
342
|
+
try {
|
|
343
|
+
const s = yield* fsLstat(path);
|
|
344
|
+
return {
|
|
345
|
+
exists: true,
|
|
346
|
+
isFile: s.isFile(),
|
|
347
|
+
isDirectory: s.isDirectory(),
|
|
348
|
+
isSymbolicLink: s.isSymbolicLink(),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
if (errorCode(err) === "ENOENT") {
|
|
353
|
+
return { exists: false, isFile: false, isDirectory: false, isSymbolicLink: false };
|
|
354
|
+
}
|
|
355
|
+
throw err;
|
|
356
|
+
}
|
|
357
|
+
},
|
|
314
358
|
*glob(options) {
|
|
315
359
|
const { patterns, root, exclude = [] } = options;
|
|
316
360
|
const matched = [];
|
|
@@ -356,18 +400,29 @@ export const API = {
|
|
|
356
400
|
Fetch: createApi("runtime.fetch", {
|
|
357
401
|
*fetch(input, init) {
|
|
358
402
|
const timeout = init?.timeout ?? (yield* contextualFetchTimeout);
|
|
359
|
-
const
|
|
403
|
+
const request = effectionFetch(input, {
|
|
360
404
|
method: init?.method,
|
|
361
405
|
headers: init?.headers,
|
|
362
406
|
body: init?.body,
|
|
363
|
-
})
|
|
364
|
-
|
|
407
|
+
});
|
|
408
|
+
const response = yield* withTimeout(`fetch(${input})`, timeout, init?.expect === true ? request.expect() : request);
|
|
409
|
+
// Read here, while the live response is still this scope's: a caller that
|
|
410
|
+
// keeps the value reads a snapshot rather than a handle on a response
|
|
411
|
+
// that has since been disposed. Collected through `forEach` rather than
|
|
412
|
+
// by iterating: the host `Headers` this reads is typed as iterable under
|
|
413
|
+
// some of the libs this package is built against and not others, and an
|
|
414
|
+
// array is iterable under all of them.
|
|
415
|
+
const entries = [];
|
|
416
|
+
response.headers.forEach((value, name) => entries.push([name, value]));
|
|
417
|
+
const headers = detachHeaders(entries);
|
|
418
|
+
const settled = {
|
|
365
419
|
status: response.status,
|
|
366
|
-
headers
|
|
420
|
+
headers,
|
|
367
421
|
*text() {
|
|
368
422
|
return yield* withTimeout(`fetch(${input}).text()`, timeout, response.text());
|
|
369
423
|
},
|
|
370
424
|
};
|
|
425
|
+
return settled;
|
|
371
426
|
},
|
|
372
427
|
}),
|
|
373
428
|
/**
|
|
@@ -421,13 +476,31 @@ export function exec(options) {
|
|
|
421
476
|
}
|
|
422
477
|
export const readTextFile = API.Fs.operations.readTextFile;
|
|
423
478
|
export const stat = API.Fs.operations.stat;
|
|
479
|
+
export const lstat = API.Fs.operations.lstat;
|
|
424
480
|
export const glob = API.Fs.operations.glob;
|
|
425
481
|
export const writeTextFile = API.Fs.operations.writeTextFile;
|
|
426
482
|
export const ensureDir = API.Fs.operations.ensureDir;
|
|
427
483
|
export const rename = API.Fs.operations.rename;
|
|
428
484
|
export const remove = API.Fs.operations.remove;
|
|
429
485
|
export const realpath = API.Fs.operations.realpath;
|
|
430
|
-
|
|
486
|
+
function chain(input, init) {
|
|
487
|
+
const settled = () => API.Fetch.operations.fetch(input, init);
|
|
488
|
+
return {
|
|
489
|
+
[Symbol.iterator]: () => settled()[Symbol.iterator](),
|
|
490
|
+
*text() {
|
|
491
|
+
return yield* (yield* settled()).text();
|
|
492
|
+
},
|
|
493
|
+
*json() {
|
|
494
|
+
return JSON.parse(yield* (yield* settled()).text());
|
|
495
|
+
},
|
|
496
|
+
// Recorded on the request rather than checked on the answer, so the whole
|
|
497
|
+
// middleware chain sees what the caller asked for.
|
|
498
|
+
expect: () => chain(input, { ...init, expect: true }),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
export function fetch(input, init) {
|
|
502
|
+
return chain(input, init);
|
|
503
|
+
}
|
|
431
504
|
export const env = API.Env.operations.env;
|
|
432
505
|
export const cwd = API.Env.operations.cwd;
|
|
433
506
|
export const platform = API.Env.operations.platform;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Deno implementation of session ownership.
|
|
3
|
+
*
|
|
4
|
+
* Constructed at a runtime-named boundary and handed to a provider by the host
|
|
5
|
+
* that built it. Shared modules never reach for it, and never ask what runtime
|
|
6
|
+
* they are on.
|
|
7
|
+
*
|
|
8
|
+
* Exclusivity is the kernel's, and durability is the filesystem's, because the
|
|
9
|
+
* two questions are different. The advisory lock answers "is another live
|
|
10
|
+
* process in this session right now", which a crash correctly ends. The
|
|
11
|
+
* ownership record answers "did the last owner prove it stopped", which a crash
|
|
12
|
+
* must *not* end — so the record is written active before any provider work and
|
|
13
|
+
* replaced with idle only after the body acknowledges quiescence. A process
|
|
14
|
+
* that dies in between releases the lock and leaves the active record, and the
|
|
15
|
+
* next owner refuses rather than inferring safety from a pid, an elapsed time,
|
|
16
|
+
* an empty transcript, or the lock being free.
|
|
17
|
+
*
|
|
18
|
+
* A process-local occupancy table sits in front of both. Advisory locks are
|
|
19
|
+
* per-process on some hosts, so two provider scopes in one process could
|
|
20
|
+
* otherwise both believe they hold the same session.
|
|
21
|
+
*/
|
|
22
|
+
import * as dntShim from "./_dnt.shims.js";
|
|
23
|
+
import { ensure, Err, Ok, scoped, until } from "effection";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { AgentSessionBusy, agentSessionKeyDigest, AgentSessionRecoveryRequired, parseAgentSessionOwnership, serializeAgentSessionOwnership, } from "./agent-session-coordinator.js";
|
|
26
|
+
/** One of the host's methods, bound to it, or nothing when it has none. */
|
|
27
|
+
function callable(host, name) {
|
|
28
|
+
const member = Reflect.get(host, name);
|
|
29
|
+
if (typeof member !== "function") {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return (...args) => Reflect.apply(member, host, args);
|
|
33
|
+
}
|
|
34
|
+
function hostFile(value) {
|
|
35
|
+
if (typeof value !== "object" || value === null) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const tryLock = callable(value, "tryLock");
|
|
39
|
+
const unlock = callable(value, "unlock");
|
|
40
|
+
const sync = callable(value, "sync");
|
|
41
|
+
const close = callable(value, "close");
|
|
42
|
+
if (!tryLock || !unlock || !sync || !close) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
tryLock: (exclusive) => Promise.resolve(tryLock(exclusive)),
|
|
47
|
+
unlock: () => Promise.resolve(unlock()),
|
|
48
|
+
sync: () => Promise.resolve(sync()),
|
|
49
|
+
close: () => {
|
|
50
|
+
close();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function coordinatorHost() {
|
|
55
|
+
if (!Reflect.has(dntShim.dntGlobalThis, "Deno")) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const host = Reflect.get(dntShim.dntGlobalThis, "Deno");
|
|
59
|
+
if (typeof host !== "object" || host === null) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const names = ["mkdir", "open", "readTextFile", "writeTextFile", "rename", "remove"];
|
|
63
|
+
const calls = {};
|
|
64
|
+
for (const name of names) {
|
|
65
|
+
const call = callable(host, name);
|
|
66
|
+
if (!call) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
calls[name] = call;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
mkdir: (path, options) => Promise.resolve(calls.mkdir(path, options)),
|
|
73
|
+
open: (path, options) => Promise.resolve(calls.open(path, options)),
|
|
74
|
+
readTextFile: (path) => Promise.resolve(calls.readTextFile(path)).then((value) => String(value)),
|
|
75
|
+
writeTextFile: (path, data, options) => Promise.resolve(calls.writeTextFile(path, data, options)),
|
|
76
|
+
rename: (from, to) => Promise.resolve(calls.rename(from, to)),
|
|
77
|
+
remove: (path) => Promise.resolve(calls.remove(path)),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Whether this host can coordinate agent sessions at all. */
|
|
81
|
+
export function hasDenoAgentSessionCoordinator() {
|
|
82
|
+
return coordinatorHost() !== undefined;
|
|
83
|
+
}
|
|
84
|
+
function isMissing(cause) {
|
|
85
|
+
return ((typeof cause === "object" && cause !== null && Reflect.get(cause, "code") === "ENOENT") ||
|
|
86
|
+
(cause instanceof Error && cause.name === "NotFound"));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Build the coordinator rooted at `root`.
|
|
90
|
+
*
|
|
91
|
+
* Returns nothing on a host with no such filesystem: a host that cannot answer
|
|
92
|
+
* the ownership question installs no coordinator, and every advertised
|
|
93
|
+
* provider-returned operation refuses.
|
|
94
|
+
*/
|
|
95
|
+
export function createDenoAgentSessionCoordinator(root) {
|
|
96
|
+
const found = coordinatorHost();
|
|
97
|
+
if (!found) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const host = found;
|
|
101
|
+
// Sibling provider scopes in one process contend here before they contend in
|
|
102
|
+
// the kernel, because an advisory lock does not always separate them.
|
|
103
|
+
const occupied = new Set();
|
|
104
|
+
function* readOwnership(path) {
|
|
105
|
+
const text = yield* until(host.readTextFile(path).catch((cause) => {
|
|
106
|
+
if (isMissing(cause)) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
throw cause;
|
|
110
|
+
}));
|
|
111
|
+
if (text === undefined) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(text);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new AgentSessionRecoveryRequired("the retained agent session ownership record is not readable");
|
|
120
|
+
}
|
|
121
|
+
const record = parseAgentSessionOwnership(parsed);
|
|
122
|
+
if (!record) {
|
|
123
|
+
throw new AgentSessionRecoveryRequired("the retained agent session ownership record is not valid");
|
|
124
|
+
}
|
|
125
|
+
return record;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Replace the ownership record durably.
|
|
129
|
+
*
|
|
130
|
+
* Written whole, flushed, renamed over the destination, and the directory
|
|
131
|
+
* flushed behind it — so a reader sees the old record or the new one, and a
|
|
132
|
+
* crash cannot leave a half-written state that parses as something weaker.
|
|
133
|
+
*/
|
|
134
|
+
function* publish(directory, destination, record) {
|
|
135
|
+
const staging = `${destination}.${record.operationId}.staging`;
|
|
136
|
+
yield* until(host.writeTextFile(staging, serializeAgentSessionOwnership(record), { mode: 0o600 }));
|
|
137
|
+
const staged = hostFile(yield* until(host.open(staging, { read: true })));
|
|
138
|
+
if (staged) {
|
|
139
|
+
yield* until(staged.sync());
|
|
140
|
+
staged.close();
|
|
141
|
+
}
|
|
142
|
+
yield* until(host.rename(staging, destination));
|
|
143
|
+
const opened = hostFile(yield* until(host.open(directory, { read: true })));
|
|
144
|
+
if (opened) {
|
|
145
|
+
yield* until(opened.sync());
|
|
146
|
+
opened.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
coordinate(key, owner, body) {
|
|
151
|
+
// Scoped, not a resource: ownership has to end when the body ends. A
|
|
152
|
+
// resource acquired in the caller's scope would hold the lock and the
|
|
153
|
+
// active record until *that* scope closed, which is a different — and
|
|
154
|
+
// much longer — lifetime than the work it protects.
|
|
155
|
+
return scoped(function* () {
|
|
156
|
+
const digest = agentSessionKeyDigest(key);
|
|
157
|
+
const leases = join(root, "leases");
|
|
158
|
+
const ownership = join(root, "ownership");
|
|
159
|
+
yield* until(host.mkdir(leases, { recursive: true, mode: 0o700 }));
|
|
160
|
+
yield* until(host.mkdir(ownership, { recursive: true, mode: 0o700 }));
|
|
161
|
+
const record = join(ownership, `${digest}.json`);
|
|
162
|
+
if (occupied.has(digest)) {
|
|
163
|
+
return Err(busy(key));
|
|
164
|
+
}
|
|
165
|
+
occupied.add(digest);
|
|
166
|
+
yield* ensure(() => {
|
|
167
|
+
occupied.delete(digest);
|
|
168
|
+
});
|
|
169
|
+
// Opened for the ownership scope and never unlinked: deleting it would
|
|
170
|
+
// let a second process create a fresh file and lock that instead.
|
|
171
|
+
const opened = hostFile(yield* until(host.open(join(leases, `${digest}.lease`), {
|
|
172
|
+
read: true,
|
|
173
|
+
write: true,
|
|
174
|
+
create: true,
|
|
175
|
+
mode: 0o600,
|
|
176
|
+
})));
|
|
177
|
+
if (!opened) {
|
|
178
|
+
throw new Error("this host opened a file with no advisory-lock operations");
|
|
179
|
+
}
|
|
180
|
+
let locked = false;
|
|
181
|
+
yield* ensure(function* () {
|
|
182
|
+
if (locked) {
|
|
183
|
+
yield* until(opened.unlock());
|
|
184
|
+
}
|
|
185
|
+
opened.close();
|
|
186
|
+
});
|
|
187
|
+
locked = (yield* until(opened.tryLock(true))) === true;
|
|
188
|
+
if (!locked) {
|
|
189
|
+
return Err(busy(key));
|
|
190
|
+
}
|
|
191
|
+
const retained = yield* readOwnership(record);
|
|
192
|
+
if (retained?.state === "active") {
|
|
193
|
+
// The lock is free and the record is not. Whoever held it last never
|
|
194
|
+
// said it had stopped, and nothing here can say it for them.
|
|
195
|
+
return Err(new AgentSessionRecoveryRequired(`session "${key.sessionKey}" was left owned by an XMD process that did not ` +
|
|
196
|
+
`finish. Nothing here can prove that owner stopped, so it stays owned until ` +
|
|
197
|
+
`it is recovered deliberately.`));
|
|
198
|
+
}
|
|
199
|
+
const active = {
|
|
200
|
+
schema: "agent-session-ownership.v1",
|
|
201
|
+
keyDigest: digest,
|
|
202
|
+
state: "active",
|
|
203
|
+
ownerKind: owner.kind,
|
|
204
|
+
operationId: owner.operationId,
|
|
205
|
+
};
|
|
206
|
+
yield* publish(ownership, record, active);
|
|
207
|
+
let quiesced = false;
|
|
208
|
+
// Registered before the body runs, so an idle record is published on
|
|
209
|
+
// every ordinary exit — and on none of the others.
|
|
210
|
+
yield* ensure(function* () {
|
|
211
|
+
if (!quiesced) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
yield* publish(ownership, record, { ...active, state: "idle" });
|
|
215
|
+
});
|
|
216
|
+
return Ok(yield* body({
|
|
217
|
+
quiesced() {
|
|
218
|
+
quiesced = true;
|
|
219
|
+
},
|
|
220
|
+
}));
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function busy(key) {
|
|
226
|
+
return new AgentSessionBusy(`another XMD owner is using session "${key.sessionKey}" — a native UI or a turn in ` +
|
|
227
|
+
`another process holds it. Run this again once that owner exits.`);
|
|
228
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Deno implementation of executable observation.
|
|
3
|
+
*
|
|
4
|
+
* Constructed at a runtime-named boundary and handed to a provider by the host
|
|
5
|
+
* that built it, exactly as the session coordinator is. Shared modules never
|
|
6
|
+
* reach for it and never ask what runtime they are on.
|
|
7
|
+
*
|
|
8
|
+
* Resolution reads the real process environment rather than a contextual one.
|
|
9
|
+
* That is the whole point of building this here: PATH decides which file is
|
|
10
|
+
* observed, and a PATH document middleware could move is a PATH that can point
|
|
11
|
+
* the observation at one binary while the run spawns another. A controlled test
|
|
12
|
+
* substitutes the entire observer through the same constructor seam the host
|
|
13
|
+
* uses, so nothing needs a replaceable resolver to be testable.
|
|
14
|
+
*/
|
|
15
|
+
import * as dntShim from "./_dnt.shims.js";
|
|
16
|
+
import { until } from "effection";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
19
|
+
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
20
|
+
import { ExecutableObservationError } from "./executable-observer.js";
|
|
21
|
+
/** One of the host's methods, bound to it, or nothing when it has none. */
|
|
22
|
+
function callable(host, name) {
|
|
23
|
+
const member = Reflect.get(host, name);
|
|
24
|
+
if (typeof member !== "function") {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return (...args) => Reflect.apply(member, host, args);
|
|
28
|
+
}
|
|
29
|
+
function observerHost() {
|
|
30
|
+
const found = Reflect.get(dntShim.dntGlobalThis, "Deno");
|
|
31
|
+
if (typeof found !== "object" || found === null) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const env = Reflect.get(found, "env");
|
|
35
|
+
const cwd = callable(found, "cwd");
|
|
36
|
+
const commandCtor = Reflect.get(found, "Command");
|
|
37
|
+
if (typeof commandCtor !== "function" || !cwd || typeof env !== "object" || env === null) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const toObject = callable(env, "toObject");
|
|
41
|
+
if (!toObject) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
command: (path, options) => Reflect.construct(commandCtor, [
|
|
46
|
+
path,
|
|
47
|
+
options,
|
|
48
|
+
]),
|
|
49
|
+
env: { toObject: () => toObject() },
|
|
50
|
+
cwd: () => cwd(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Whether this host can observe an executable at all. */
|
|
54
|
+
export function hasDenoExecutableObserver() {
|
|
55
|
+
return observerHost() !== undefined;
|
|
56
|
+
}
|
|
57
|
+
/** What the version invocation produced, decoded. */
|
|
58
|
+
function decode(value) {
|
|
59
|
+
if (typeof value !== "object" || value === null) {
|
|
60
|
+
return { code: -1, text: "" };
|
|
61
|
+
}
|
|
62
|
+
const code = Reflect.get(value, "code");
|
|
63
|
+
const stdout = Reflect.get(value, "stdout");
|
|
64
|
+
const decoder = new TextDecoder();
|
|
65
|
+
return {
|
|
66
|
+
code: typeof code === "number" ? code : -1,
|
|
67
|
+
text: stdout instanceof Uint8Array ? decoder.decode(stdout) : "",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Build an observer rooted in this process's real environment.
|
|
72
|
+
*
|
|
73
|
+
* `overrides` exist for the focused proof only: a test that wants to watch
|
|
74
|
+
* PATH search happen supplies its own search path and working directory rather
|
|
75
|
+
* than moving the ones every other thing in the process is using.
|
|
76
|
+
*/
|
|
77
|
+
export function createDenoExecutableObserver(overrides) {
|
|
78
|
+
const found = observerHost();
|
|
79
|
+
if (!found) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const host = found;
|
|
83
|
+
function* resolveCommand(command) {
|
|
84
|
+
if (command.length === 0) {
|
|
85
|
+
throw new ExecutableObservationError("no executable was named", { refusal: "not-found" });
|
|
86
|
+
}
|
|
87
|
+
const base = overrides?.cwd ?? host.cwd();
|
|
88
|
+
if (command.includes("/") || command.includes("\\") || isAbsolute(command)) {
|
|
89
|
+
return resolve(base, command);
|
|
90
|
+
}
|
|
91
|
+
const search = overrides?.path ?? host.env.toObject().PATH ?? "";
|
|
92
|
+
for (const entry of search.split(delimiter)) {
|
|
93
|
+
if (entry.length === 0) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const candidate = join(entry, command);
|
|
97
|
+
const found = yield* until(stat(candidate).then(() => true, () => false));
|
|
98
|
+
if (found) {
|
|
99
|
+
return candidate;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw new ExecutableObservationError(`no executable named ${command} was found on the search path`, { refusal: "not-found" });
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
*observe(command, options) {
|
|
106
|
+
const resolved = yield* resolveCommand(command);
|
|
107
|
+
// Canonicalized before stat, hash and version: a symlinked launcher shim
|
|
108
|
+
// and the build it points at are one file, so the same build reached two
|
|
109
|
+
// ways produces one digest — and the version comes from that same file
|
|
110
|
+
// rather than from whatever the shim would have re-resolved.
|
|
111
|
+
const path = yield* until(realpath(resolved).catch(() => resolved));
|
|
112
|
+
const info = yield* until(stat(path).catch((cause) => {
|
|
113
|
+
throw new ExecutableObservationError(`${command} could not be inspected`, {
|
|
114
|
+
refusal: "not-found",
|
|
115
|
+
cause,
|
|
116
|
+
});
|
|
117
|
+
}));
|
|
118
|
+
if (!info.isFile()) {
|
|
119
|
+
throw new ExecutableObservationError(`${command} does not name a regular file`, {
|
|
120
|
+
refusal: "not-a-file",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// Any execute bit is enough: which one applies depends on who is asking,
|
|
124
|
+
// and a file with none of them is not a program under any of them.
|
|
125
|
+
if ((info.mode & 0o111) === 0) {
|
|
126
|
+
throw new ExecutableObservationError(`${command} is not executable`, {
|
|
127
|
+
refusal: "not-executable",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const bytes = yield* until(readFile(path).catch((cause) => {
|
|
131
|
+
throw new ExecutableObservationError(`${command} could not be read`, {
|
|
132
|
+
refusal: "unreadable",
|
|
133
|
+
cause,
|
|
134
|
+
});
|
|
135
|
+
}));
|
|
136
|
+
const versionArgs = options?.versionArgs ?? ["--version"];
|
|
137
|
+
const produced = yield* until(host
|
|
138
|
+
.command(path, { args: [...versionArgs], stdout: "piped", stderr: "null" })
|
|
139
|
+
.output()
|
|
140
|
+
.catch((cause) => {
|
|
141
|
+
throw new ExecutableObservationError(`${command} could not be asked its version`, {
|
|
142
|
+
refusal: "version-unavailable",
|
|
143
|
+
cause,
|
|
144
|
+
});
|
|
145
|
+
}));
|
|
146
|
+
const version = decode(produced);
|
|
147
|
+
if (version.code !== 0) {
|
|
148
|
+
throw new ExecutableObservationError(`${command} refused to report a version`, {
|
|
149
|
+
refusal: "version-unavailable",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
path,
|
|
154
|
+
digest: { algorithm: "sha256", value: createHash("sha256").update(bytes).digest("hex") },
|
|
155
|
+
versionOutput: version.text,
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|