@abaigram/abai-stub 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +22 -2
- package/dist/audit.d.ts +102 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +196 -0
- package/dist/audit.js.map +1 -0
- package/dist/cells/fit.d.ts +6 -0
- package/dist/cells/fit.d.ts.map +1 -0
- package/dist/cells/fit.js +44 -0
- package/dist/cells/fit.js.map +1 -0
- package/dist/content.d.ts +17 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +71 -0
- package/dist/content.js.map +1 -0
- package/dist/endpoint.d.ts +90 -0
- package/dist/endpoint.d.ts.map +1 -0
- package/dist/endpoint.js +804 -0
- package/dist/endpoint.js.map +1 -0
- package/dist/entry.d.ts +36 -0
- package/dist/entry.d.ts.map +1 -0
- package/dist/entry.js +409 -0
- package/dist/entry.js.map +1 -0
- package/dist/env.d.ts +38 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +69 -0
- package/dist/env.js.map +1 -0
- package/dist/harness-binding.d.ts +235 -0
- package/dist/harness-binding.d.ts.map +1 -0
- package/dist/harness-binding.js +618 -0
- package/dist/harness-binding.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest/index.d.ts +3 -0
- package/dist/manifest/index.d.ts.map +1 -0
- package/dist/manifest/index.js +3 -0
- package/dist/manifest/index.js.map +1 -0
- package/dist/manifest/manifest.d.ts +177 -0
- package/dist/manifest/manifest.d.ts.map +1 -0
- package/dist/manifest/manifest.js +452 -0
- package/dist/manifest/manifest.js.map +1 -0
- package/dist/manifest/render.d.ts +6 -0
- package/dist/manifest/render.d.ts.map +1 -0
- package/dist/manifest/render.js +39 -0
- package/dist/manifest/render.js.map +1 -0
- package/dist/null-harness.d.ts +32 -0
- package/dist/null-harness.d.ts.map +1 -0
- package/dist/null-harness.js +162 -0
- package/dist/null-harness.js.map +1 -0
- package/dist/program.d.ts +255 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +898 -0
- package/dist/program.js.map +1 -0
- package/dist/region.d.ts +72 -0
- package/dist/region.d.ts.map +1 -0
- package/dist/region.js +93 -0
- package/dist/region.js.map +1 -0
- package/dist/registry.d.ts +60 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +105 -0
- package/dist/registry.js.map +1 -0
- package/dist/wire/descriptor.d.ts +38 -0
- package/dist/wire/descriptor.d.ts.map +1 -0
- package/dist/wire/descriptor.js +144 -0
- package/dist/wire/descriptor.js.map +1 -0
- package/dist/wire/framing.d.ts +11 -0
- package/dist/wire/framing.d.ts.map +1 -0
- package/dist/wire/framing.js +42 -0
- package/dist/wire/framing.js.map +1 -0
- package/dist/wire/index.d.ts +4 -0
- package/dist/wire/index.d.ts.map +1 -0
- package/dist/wire/index.js +4 -0
- package/dist/wire/index.js.map +1 -0
- package/dist/wire/protocol.d.ts +257 -0
- package/dist/wire/protocol.d.ts.map +1 -0
- package/dist/wire/protocol.js +17 -0
- package/dist/wire/protocol.js.map +1 -0
- package/package.json +36 -5
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
// The direct face (abaigrammar section 4.1): what the stub hands a harness
|
|
2
|
+
// that attaches in-process. One object serves both sides. To the program it
|
|
3
|
+
// is the AttachedHarness that decisions, turns, touchpoints and emits reach;
|
|
4
|
+
// to the harness it is the channels: cells and events to read, knobs to
|
|
5
|
+
// write, capabilities to call, decisions and turns to answer, the audit log
|
|
6
|
+
// to tail, the counters to take. Every act of control is audited here, on
|
|
7
|
+
// the program side, as section 10 says, with the consent the harness states
|
|
8
|
+
// for an act whose policy is `confirm`.
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { AUDIT_VALUE_CAP, AuditLog, capField, capValue, summarizeAnswer, } from "./audit.js";
|
|
11
|
+
import { validateKnobValue } from "./cells/fit.js";
|
|
12
|
+
import { expandHome } from "./env.js";
|
|
13
|
+
import { durationMs, fieldOf, matchesType, } from "./manifest/manifest.js";
|
|
14
|
+
/** A capability call that did not return a result. */
|
|
15
|
+
export class CallError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "CallError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A touchpoint has no timeout of its own, so nothing of the program's holds
|
|
25
|
+
* its event loop open while a harness in another process holds the
|
|
26
|
+
* touchpoint; this ref'd timer does, and never fires in practice (the
|
|
27
|
+
* longest delay a timer takes). Cleared when the touchpoint ends.
|
|
28
|
+
*/
|
|
29
|
+
const TOUCHPOINT_KEEPALIVE_MS = 2 ** 31 - 1;
|
|
30
|
+
/**
|
|
31
|
+
* Check a value against a schema: an object with no undeclared field, whose
|
|
32
|
+
* every present field is of its declared type. A declared field may be
|
|
33
|
+
* absent (decided 2026-10-14: the schema language has no required marker,
|
|
34
|
+
* and a manifest's own defaults omit fields); `nullable` governs an
|
|
35
|
+
* explicit null. Nested objects and lists are checked by `matchesType`.
|
|
36
|
+
*/
|
|
37
|
+
export function schemaProblem(schema, value, what) {
|
|
38
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
39
|
+
return `${what} must be an object`;
|
|
40
|
+
const record = value;
|
|
41
|
+
for (const [key, field] of Object.entries(schema)) {
|
|
42
|
+
const item = record[key];
|
|
43
|
+
if (item === undefined)
|
|
44
|
+
continue;
|
|
45
|
+
if (!matchesType(item, field))
|
|
46
|
+
return `${what}.${key} must be a ${fieldOf(field).type}`;
|
|
47
|
+
}
|
|
48
|
+
for (const key of Object.keys(record)) {
|
|
49
|
+
if (!(key in schema))
|
|
50
|
+
return `${what} has an undeclared field ${key}`;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
function errorMessage(error) {
|
|
55
|
+
return error instanceof Error ? error.message : String(error);
|
|
56
|
+
}
|
|
57
|
+
function consentGranted(consent) {
|
|
58
|
+
return consent === "approved" || consent === "auto";
|
|
59
|
+
}
|
|
60
|
+
export class DirectBinding {
|
|
61
|
+
manifest;
|
|
62
|
+
name;
|
|
63
|
+
connection;
|
|
64
|
+
mode;
|
|
65
|
+
app;
|
|
66
|
+
audit;
|
|
67
|
+
listeners = {
|
|
68
|
+
event: new Set(),
|
|
69
|
+
decision: new Set(),
|
|
70
|
+
decision_timeout: new Set(),
|
|
71
|
+
turn: new Set(),
|
|
72
|
+
touchpoint: new Set(),
|
|
73
|
+
detach: new Set(),
|
|
74
|
+
};
|
|
75
|
+
decisions = new Map();
|
|
76
|
+
turns = new Map();
|
|
77
|
+
touchpoints = new Map();
|
|
78
|
+
capabilityIndex = new Map();
|
|
79
|
+
/** Touchpoints in progress: while one is open the program sits at a safepoint, so queued work runs at once (section 5.3). */
|
|
80
|
+
openTouchpoints = 0;
|
|
81
|
+
nextId = 1;
|
|
82
|
+
_attached = true;
|
|
83
|
+
constructor(app, options = {}) {
|
|
84
|
+
this.app = app;
|
|
85
|
+
this.manifest = app.manifest;
|
|
86
|
+
this.name = app.manifest.name;
|
|
87
|
+
this.connection = options.connection ?? "direct";
|
|
88
|
+
this.mode = app.mode;
|
|
89
|
+
for (const capability of this.manifest.capabilities)
|
|
90
|
+
this.capabilityIndex.set(capability.name, capability);
|
|
91
|
+
const auditPath = options.auditPath ?? resolve(app.content.root, expandHome(this.manifest.audit.path));
|
|
92
|
+
this.audit = new AuditLog(auditPath, (error) => {
|
|
93
|
+
app.emitLibrary("abai.audit.failed", { error });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
get attached() {
|
|
97
|
+
return this._attached;
|
|
98
|
+
}
|
|
99
|
+
/** @internal Called by the program once the binding is set: the counters go to the log first (section 10). */
|
|
100
|
+
flushCounters() {
|
|
101
|
+
const counters = this.app.takeCounters();
|
|
102
|
+
for (const [name, count] of Object.entries(counters)) {
|
|
103
|
+
if (count === 0)
|
|
104
|
+
continue;
|
|
105
|
+
const cause = name === "abai.mode_fallback" ? "mode_fallback" : "no_harness";
|
|
106
|
+
this.audit.append({ act: "counters", name, outcome: "default", cause, count });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Queue work for the program's next safepoint, or run it now when a touchpoint holds the program at one. */
|
|
110
|
+
atSafepoint(work) {
|
|
111
|
+
this.app.enqueue(work);
|
|
112
|
+
if (this.openTouchpoints > 0)
|
|
113
|
+
this.app.drain();
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* @internal The program is at a safepoint and will stay there (its entry
|
|
117
|
+
* serving an MCP client): safepoint-dispatched work runs at once rather
|
|
118
|
+
* than waiting for a safepoint the program would never reach. Paired
|
|
119
|
+
* with `leaveSafepoint`.
|
|
120
|
+
*/
|
|
121
|
+
enterSafepoint() {
|
|
122
|
+
this.openTouchpoints += 1;
|
|
123
|
+
this.app.drain();
|
|
124
|
+
}
|
|
125
|
+
/** @internal */
|
|
126
|
+
leaveSafepoint() {
|
|
127
|
+
if (this.openTouchpoints > 0)
|
|
128
|
+
this.openTouchpoints -= 1;
|
|
129
|
+
}
|
|
130
|
+
// -----------------------------------------------------------------------
|
|
131
|
+
// Harness side
|
|
132
|
+
// -----------------------------------------------------------------------
|
|
133
|
+
content() {
|
|
134
|
+
const slots = this.app.content;
|
|
135
|
+
return {
|
|
136
|
+
identity: slots.identity(),
|
|
137
|
+
install: slots.install(),
|
|
138
|
+
use: slots.use(),
|
|
139
|
+
turn: (name) => slots.turn(name),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
readCells(names) {
|
|
143
|
+
const all = this.app.readCells();
|
|
144
|
+
const cells = {};
|
|
145
|
+
for (const name of names ?? Object.keys(all)) {
|
|
146
|
+
if (!(name in all))
|
|
147
|
+
throw new Error(`unknown cell: ${name}`);
|
|
148
|
+
cells[name] = { value: all[name], version: this.app.version(name) };
|
|
149
|
+
}
|
|
150
|
+
return { seq: this.app.region.lastSeq, cells };
|
|
151
|
+
}
|
|
152
|
+
readEvents(after) {
|
|
153
|
+
const read = this.app.region.readEvents(after);
|
|
154
|
+
return { events: read.slots.map((slot) => this.live(slot)), dropped: read.dropped, next: read.next };
|
|
155
|
+
}
|
|
156
|
+
live(slot) {
|
|
157
|
+
return {
|
|
158
|
+
seq: slot.seq,
|
|
159
|
+
name: this.app.eventName(slot.event),
|
|
160
|
+
payload: slot.payload,
|
|
161
|
+
truncated: slot.truncated,
|
|
162
|
+
ts: slot.ts,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
async writeKnob(name, value, reason, consent, conn) {
|
|
166
|
+
const decl = this.manifest.cells.find((cell) => cell.name === name);
|
|
167
|
+
const started = this.app.now();
|
|
168
|
+
const record = (outcome, landed, error, cause) => {
|
|
169
|
+
this.audit.append({
|
|
170
|
+
conn: conn ?? this.connection,
|
|
171
|
+
act: "knob",
|
|
172
|
+
name,
|
|
173
|
+
value: capValue(landed),
|
|
174
|
+
outcome,
|
|
175
|
+
...(cause === undefined ? {} : { cause }),
|
|
176
|
+
...(consent === undefined ? {} : { consent }),
|
|
177
|
+
reason,
|
|
178
|
+
...(error === undefined ? {} : { error }),
|
|
179
|
+
ms: this.app.now() - started,
|
|
180
|
+
});
|
|
181
|
+
return outcome === "ok"
|
|
182
|
+
? { value: landed, outcome: "done" }
|
|
183
|
+
: { value: landed, outcome: "failed", error, ...(cause === undefined ? {} : { code: cause }) };
|
|
184
|
+
};
|
|
185
|
+
if (!decl)
|
|
186
|
+
return record("error", undefined, `unknown cell: ${name}`, "unknown");
|
|
187
|
+
if (decl.access !== "readwrite")
|
|
188
|
+
return record("error", undefined, `${name} is read-only`, "invalid");
|
|
189
|
+
if (decl.policy === "confirm" && !consentGranted(consent)) {
|
|
190
|
+
return record("error", undefined, `${name} needs consent (its policy is confirm)`, "denied");
|
|
191
|
+
}
|
|
192
|
+
const problem = validateKnobValue(decl, value);
|
|
193
|
+
if (problem !== undefined)
|
|
194
|
+
return record("error", undefined, problem, "invalid");
|
|
195
|
+
const sized = decl.type === "string" || decl.type === "object" || decl.type === "list";
|
|
196
|
+
if (!sized) {
|
|
197
|
+
this.app.knob(name).set(value);
|
|
198
|
+
return record("ok", this.app.cell(name).get());
|
|
199
|
+
}
|
|
200
|
+
// a sized knob is delivered at the program's next safepoint, to its handler, within its delivery timeout (section 5.2)
|
|
201
|
+
const handler = this.app.knobHandler(name);
|
|
202
|
+
if (!handler) {
|
|
203
|
+
return record("failed", undefined, `no handler registered for ${name}: nothing can receive it`, "error");
|
|
204
|
+
}
|
|
205
|
+
const timeoutMs = durationMs(decl.timeout ?? "30s");
|
|
206
|
+
return new Promise((done) => {
|
|
207
|
+
let settled = false;
|
|
208
|
+
const work = () => {
|
|
209
|
+
if (settled)
|
|
210
|
+
return;
|
|
211
|
+
settled = true;
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
this.app.cell(name).set(value);
|
|
214
|
+
const landed = this.app.cell(name).get();
|
|
215
|
+
try {
|
|
216
|
+
handler(landed);
|
|
217
|
+
done(record("ok", landed));
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
done(record("error", landed, errorMessage(error), "error"));
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const timer = setTimeout(() => {
|
|
224
|
+
if (settled)
|
|
225
|
+
return;
|
|
226
|
+
settled = true;
|
|
227
|
+
this.app.dequeue(work);
|
|
228
|
+
done(record("failed", undefined, `not delivered within ${decl.timeout ?? "30s"}: the program reached no safepoint`, "timeout"));
|
|
229
|
+
}, timeoutMs);
|
|
230
|
+
timer.unref();
|
|
231
|
+
this.atSafepoint(work);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
async call(name, params, reason, consent, conn) {
|
|
235
|
+
const decl = this.capabilityIndex.get(name);
|
|
236
|
+
const started = this.app.now();
|
|
237
|
+
const record = (outcome, extra = {}) => {
|
|
238
|
+
this.audit.append({
|
|
239
|
+
conn: conn ?? this.connection,
|
|
240
|
+
act: "capability",
|
|
241
|
+
name,
|
|
242
|
+
params: capValue(params),
|
|
243
|
+
outcome,
|
|
244
|
+
...(consent === undefined ? {} : { consent }),
|
|
245
|
+
reason,
|
|
246
|
+
...extra,
|
|
247
|
+
ms: this.app.now() - started,
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
const fail = (code, message) => {
|
|
251
|
+
record(code === "forbidden" ? "forbidden" : "error", { error: message, cause: code });
|
|
252
|
+
return new CallError(code, message);
|
|
253
|
+
};
|
|
254
|
+
if (!decl)
|
|
255
|
+
throw fail("unknown", `${this.name} declares no capability ${name}`);
|
|
256
|
+
if (decl.policy === "forbid")
|
|
257
|
+
throw fail("forbidden", `the program's policy forbids ${name}`);
|
|
258
|
+
if (decl.policy === "confirm" && !consentGranted(consent))
|
|
259
|
+
throw fail("denied", `${name} needs consent (its policy is confirm)`);
|
|
260
|
+
const problem = schemaProblem(decl.params, params ?? {}, "params");
|
|
261
|
+
if (problem !== undefined)
|
|
262
|
+
throw fail("invalid", problem);
|
|
263
|
+
const timeoutMs = durationMs(decl.timeout ?? "30s");
|
|
264
|
+
const result = await new Promise((done, reject) => {
|
|
265
|
+
let settled = false;
|
|
266
|
+
const settle = (fn) => {
|
|
267
|
+
if (settled)
|
|
268
|
+
return;
|
|
269
|
+
settled = true;
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
fn();
|
|
272
|
+
};
|
|
273
|
+
// the handler is resolved at dispatch, so one registered after startup still serves
|
|
274
|
+
const run = () => {
|
|
275
|
+
if (settled)
|
|
276
|
+
return;
|
|
277
|
+
const handler = this.app.capabilityHandler(name);
|
|
278
|
+
if (!handler) {
|
|
279
|
+
settle(() => reject(new CallError("error", `${name} has no handler registered`)));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
let outcome;
|
|
283
|
+
try {
|
|
284
|
+
outcome = handler(params ?? {}); // synchronous part runs inside the safepoint, on the program's thread
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
settle(() => reject(new CallError("error", errorMessage(error))));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
Promise.resolve(outcome).then((value) => settle(() => done(value)), (error) => settle(() => reject(new CallError("error", errorMessage(error)))));
|
|
291
|
+
};
|
|
292
|
+
const timer = setTimeout(() => {
|
|
293
|
+
if (settled)
|
|
294
|
+
return;
|
|
295
|
+
settled = true;
|
|
296
|
+
this.app.dequeue(run);
|
|
297
|
+
reject(new CallError("timeout", `${name} did not answer within ${decl.timeout ?? "30s"}`));
|
|
298
|
+
}, timeoutMs);
|
|
299
|
+
timer.unref();
|
|
300
|
+
if (decl.dispatch === "immediate")
|
|
301
|
+
run();
|
|
302
|
+
else
|
|
303
|
+
this.atSafepoint(run);
|
|
304
|
+
}).catch((error) => {
|
|
305
|
+
record("error", { error: error.message, cause: error.code });
|
|
306
|
+
throw error;
|
|
307
|
+
});
|
|
308
|
+
record("ok");
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
recordMessage(record, conn) {
|
|
312
|
+
const cut = (value, cap) => value === undefined ? undefined : capField(value, cap);
|
|
313
|
+
const thread = cut(record.thread);
|
|
314
|
+
const re = cut(record.re);
|
|
315
|
+
const reason = cut(record.reason, AUDIT_VALUE_CAP);
|
|
316
|
+
const error = cut(record.error, AUDIT_VALUE_CAP);
|
|
317
|
+
const id = cut(record.id);
|
|
318
|
+
return this.audit.append({
|
|
319
|
+
conn: conn ?? this.connection,
|
|
320
|
+
act: "message",
|
|
321
|
+
name: capField(record.peer),
|
|
322
|
+
...(id === undefined ? {} : { id }),
|
|
323
|
+
params: {
|
|
324
|
+
direction: record.direction,
|
|
325
|
+
network: capField(record.network),
|
|
326
|
+
addr: capField(record.addr),
|
|
327
|
+
kind: record.kind,
|
|
328
|
+
...(thread === undefined ? {} : { thread }),
|
|
329
|
+
...(re === undefined ? {} : { re }),
|
|
330
|
+
},
|
|
331
|
+
value: capValue(record.body),
|
|
332
|
+
outcome: record.outcome,
|
|
333
|
+
...(reason === undefined ? {} : { reason }),
|
|
334
|
+
...(error === undefined ? {} : { error }),
|
|
335
|
+
...(typeof record.ms === "number" && Number.isFinite(record.ms) ? { ms: record.ms } : {}),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
auditTail(limit) {
|
|
339
|
+
return this.audit.tail(limit);
|
|
340
|
+
}
|
|
341
|
+
auditInfo() {
|
|
342
|
+
return { path: this.audit.path, failed: this.audit.failed ?? null };
|
|
343
|
+
}
|
|
344
|
+
takeCounters() {
|
|
345
|
+
return this.app.takeCounters();
|
|
346
|
+
}
|
|
347
|
+
on(type, listener) {
|
|
348
|
+
const set = this.listeners[type];
|
|
349
|
+
set.add(listener);
|
|
350
|
+
return () => {
|
|
351
|
+
set.delete(listener);
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
notify(type, ...args) {
|
|
355
|
+
for (const listener of this.listeners[type]) {
|
|
356
|
+
try {
|
|
357
|
+
listener(...args);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// a harness listener's failure never reaches the program
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
answerDecision(id, answer, reason, consent, conn) {
|
|
365
|
+
const pending = this.decisions.get(id);
|
|
366
|
+
if (!pending)
|
|
367
|
+
throw new Error(`no pending decision ${id}`);
|
|
368
|
+
if (reason.trim() === "")
|
|
369
|
+
throw new Error("a decision needs a reason");
|
|
370
|
+
if (conn !== undefined)
|
|
371
|
+
pending.conn = conn;
|
|
372
|
+
const { decision } = pending.request;
|
|
373
|
+
if (answer !== null) {
|
|
374
|
+
const problem = decision.options
|
|
375
|
+
? decision.options.includes(answer)
|
|
376
|
+
? undefined
|
|
377
|
+
: `answer must be one of: ${decision.options.join(", ")}`
|
|
378
|
+
: schemaProblem(decision.answer ?? {}, answer, "answer");
|
|
379
|
+
if (problem !== undefined) {
|
|
380
|
+
this.settleDecision(id, { source: "default", cause: "invalid", reason, answer });
|
|
381
|
+
throw new Error(problem);
|
|
382
|
+
}
|
|
383
|
+
if (decision.policy === "confirm" && !consentGranted(consent)) {
|
|
384
|
+
this.settleDecision(id, { source: "default", cause: "denied", reason, consent });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
this.settleDecision(id, answer === null
|
|
389
|
+
? { source: "default", cause: "answered", reason }
|
|
390
|
+
: { source: "agent", reason, answer, consent });
|
|
391
|
+
}
|
|
392
|
+
defaultDecision(id, cause, conn) {
|
|
393
|
+
const pending = this.decisions.get(id);
|
|
394
|
+
if (!pending)
|
|
395
|
+
return;
|
|
396
|
+
if (conn !== undefined)
|
|
397
|
+
pending.conn = conn;
|
|
398
|
+
this.settleDecision(id, { source: "default", cause });
|
|
399
|
+
}
|
|
400
|
+
assignDecision(id, conn) {
|
|
401
|
+
const pending = this.decisions.get(id);
|
|
402
|
+
if (pending)
|
|
403
|
+
pending.conn = conn;
|
|
404
|
+
}
|
|
405
|
+
pendingDecisions() {
|
|
406
|
+
return [...this.decisions.values()].map((p) => p.request);
|
|
407
|
+
}
|
|
408
|
+
settleDecision(id, outcome) {
|
|
409
|
+
const pending = this.decisions.get(id);
|
|
410
|
+
if (!pending)
|
|
411
|
+
return;
|
|
412
|
+
this.decisions.delete(id);
|
|
413
|
+
if (pending.timer)
|
|
414
|
+
clearTimeout(pending.timer);
|
|
415
|
+
this.openTouchpoints -= 1;
|
|
416
|
+
const { decision } = pending.request;
|
|
417
|
+
this.audit.append({
|
|
418
|
+
conn: pending.conn ?? this.connection,
|
|
419
|
+
act: "decision",
|
|
420
|
+
name: decision.name,
|
|
421
|
+
id,
|
|
422
|
+
value: summarizeAnswer(decision, outcome.source === "agent" ? outcome.answer : decision.default),
|
|
423
|
+
outcome: outcome.source,
|
|
424
|
+
...(outcome.cause === undefined ? {} : { cause: outcome.cause }),
|
|
425
|
+
...(outcome.consent === undefined ? {} : { consent: outcome.consent }),
|
|
426
|
+
...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
|
|
427
|
+
ms: this.app.now() - pending.startedAt,
|
|
428
|
+
});
|
|
429
|
+
if (outcome.source === "agent")
|
|
430
|
+
pending.resolve(outcome.answer);
|
|
431
|
+
else
|
|
432
|
+
pending.reject(new Error(outcome.cause ?? "default"));
|
|
433
|
+
if (outcome.cause === "timeout")
|
|
434
|
+
this.notify("decision_timeout", id, decision.name);
|
|
435
|
+
}
|
|
436
|
+
endTurn(id, conn, cause) {
|
|
437
|
+
const pending = this.turns.get(id);
|
|
438
|
+
if (!pending)
|
|
439
|
+
return;
|
|
440
|
+
if (conn !== undefined)
|
|
441
|
+
pending.conn = conn;
|
|
442
|
+
// a turn the agent did nothing with (held back by its budget) is a default, with the cause it names
|
|
443
|
+
if (cause === undefined)
|
|
444
|
+
this.settleTurn(id, "ok");
|
|
445
|
+
else
|
|
446
|
+
this.settleTurn(id, "default", cause);
|
|
447
|
+
}
|
|
448
|
+
/** @internal Queue work for the program's next safepoint, running it now when one is held open. */
|
|
449
|
+
atNextSafepoint(work) {
|
|
450
|
+
this.atSafepoint(work);
|
|
451
|
+
}
|
|
452
|
+
lifecycle(op, reason, consent, conn) {
|
|
453
|
+
const declared = this.manifest.lifecycle;
|
|
454
|
+
const started = this.app.now();
|
|
455
|
+
const record = (outcome, extra = {}) => {
|
|
456
|
+
this.audit.append({
|
|
457
|
+
conn: conn ?? this.connection,
|
|
458
|
+
act: "lifecycle",
|
|
459
|
+
name: op,
|
|
460
|
+
outcome,
|
|
461
|
+
...(consent === undefined ? {} : { consent }),
|
|
462
|
+
reason,
|
|
463
|
+
...extra,
|
|
464
|
+
ms: this.app.now() - started,
|
|
465
|
+
});
|
|
466
|
+
};
|
|
467
|
+
const refuse = (code, error) => {
|
|
468
|
+
record(code === "forbidden" ? "forbidden" : "error", { cause: code, error });
|
|
469
|
+
return { ok: false, code, error };
|
|
470
|
+
};
|
|
471
|
+
if (op === "status")
|
|
472
|
+
return { ok: true, data: this.app.lifecycleStatus() };
|
|
473
|
+
if (op !== "pause" && op !== "resume" && op !== "stop" && op !== "restart" && op !== "start") {
|
|
474
|
+
return { ok: false, code: "unknown", error: `unknown lifecycle operation: ${op}` };
|
|
475
|
+
}
|
|
476
|
+
// section 10: pause and resume are auto, stop and restart confirm, unless the manifest says otherwise
|
|
477
|
+
const policy = declared?.policy?.[op] ??
|
|
478
|
+
(op === "stop" || op === "restart" ? "confirm" : "auto");
|
|
479
|
+
if (policy === "forbid")
|
|
480
|
+
return refuse("forbidden", `the program's policy forbids lifecycle ${op}`);
|
|
481
|
+
if (policy === "confirm" && !consentGranted(consent)) {
|
|
482
|
+
return refuse("denied", `lifecycle ${op} needs consent (its policy is confirm)`);
|
|
483
|
+
}
|
|
484
|
+
switch (op) {
|
|
485
|
+
case "pause":
|
|
486
|
+
if (declared?.pause !== "cooperative") {
|
|
487
|
+
return refuse("invalid", declared?.pause === "os"
|
|
488
|
+
? "this program declares os pause, which its launcher performs"
|
|
489
|
+
: "this program declares no cooperative pause");
|
|
490
|
+
}
|
|
491
|
+
this.app.requestPause();
|
|
492
|
+
record("ok");
|
|
493
|
+
return { ok: true };
|
|
494
|
+
case "resume":
|
|
495
|
+
this.app.resumeFromPause();
|
|
496
|
+
record("ok");
|
|
497
|
+
return { ok: true };
|
|
498
|
+
case "stop": {
|
|
499
|
+
if (!this.app.stopHandler)
|
|
500
|
+
return refuse("invalid", "this program declares no stop handler");
|
|
501
|
+
// a stop supersedes a pause: a program held at a touchpoint goes on to the safepoint where the handler runs
|
|
502
|
+
this.app.resumeFromPause();
|
|
503
|
+
// at the next safepoint, on the thread that reaches it (5.5); the program is expected to exit
|
|
504
|
+
const handler = this.app.stopHandler;
|
|
505
|
+
this.atNextSafepoint(() => {
|
|
506
|
+
// on the thread that reached the safepoint; a failing handler is the program's own matter, and the
|
|
507
|
+
// launcher's window runs either way
|
|
508
|
+
try {
|
|
509
|
+
Promise.resolve(handler()).catch(() => { });
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
// same
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
record("ok");
|
|
516
|
+
return { ok: true, data: { op, requested: true } };
|
|
517
|
+
}
|
|
518
|
+
default:
|
|
519
|
+
return refuse("invalid", `lifecycle ${op} is the launcher's, not the program's`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
settleTurn(id, outcome, cause) {
|
|
523
|
+
const pending = this.turns.get(id);
|
|
524
|
+
if (!pending)
|
|
525
|
+
return;
|
|
526
|
+
this.turns.delete(id);
|
|
527
|
+
if (pending.timer)
|
|
528
|
+
clearTimeout(pending.timer);
|
|
529
|
+
this.openTouchpoints -= 1;
|
|
530
|
+
this.audit.append({
|
|
531
|
+
conn: pending.conn ?? this.connection,
|
|
532
|
+
act: "turn",
|
|
533
|
+
name: pending.request.name,
|
|
534
|
+
id,
|
|
535
|
+
outcome,
|
|
536
|
+
...(cause === undefined ? {} : { cause }),
|
|
537
|
+
ms: this.app.now() - pending.startedAt,
|
|
538
|
+
});
|
|
539
|
+
pending.resolve();
|
|
540
|
+
}
|
|
541
|
+
endTouchpoint(id) {
|
|
542
|
+
const pending = this.touchpoints.get(id);
|
|
543
|
+
if (!pending)
|
|
544
|
+
return;
|
|
545
|
+
this.touchpoints.delete(id);
|
|
546
|
+
if (pending.timer)
|
|
547
|
+
clearInterval(pending.timer);
|
|
548
|
+
this.openTouchpoints -= 1;
|
|
549
|
+
pending.resolve();
|
|
550
|
+
}
|
|
551
|
+
detach(reason) {
|
|
552
|
+
if (!this._attached)
|
|
553
|
+
return;
|
|
554
|
+
this._attached = false;
|
|
555
|
+
for (const id of [...this.decisions.keys()])
|
|
556
|
+
this.settleDecision(id, { source: "default", cause: "detached", reason });
|
|
557
|
+
for (const id of [...this.turns.keys()])
|
|
558
|
+
this.settleTurn(id, "default", "detached");
|
|
559
|
+
for (const id of [...this.touchpoints.keys()])
|
|
560
|
+
this.endTouchpoint(id);
|
|
561
|
+
this.notify("detach", reason);
|
|
562
|
+
this.app.clearHarness(reason);
|
|
563
|
+
this.audit.close();
|
|
564
|
+
}
|
|
565
|
+
// -----------------------------------------------------------------------
|
|
566
|
+
// Program side (AttachedHarness)
|
|
567
|
+
// -----------------------------------------------------------------------
|
|
568
|
+
decide(decision, context, deadline) {
|
|
569
|
+
if (decision.policy === "forbid") {
|
|
570
|
+
this.audit.append({
|
|
571
|
+
conn: this.connection,
|
|
572
|
+
act: "decision",
|
|
573
|
+
name: decision.name,
|
|
574
|
+
outcome: "default",
|
|
575
|
+
cause: "forbidden",
|
|
576
|
+
});
|
|
577
|
+
return Promise.reject(new Error("forbidden"));
|
|
578
|
+
}
|
|
579
|
+
const id = `d${this.nextId++}`;
|
|
580
|
+
const request = { id, name: decision.name, decision, context, deadline };
|
|
581
|
+
return new Promise((resolve, reject) => {
|
|
582
|
+
// this timer bounds the program's own wait: it keeps the process alive until the answer or the default
|
|
583
|
+
const timer = setTimeout(() => this.settleDecision(id, { source: "default", cause: "timeout" }), Math.max(0, deadline - this.app.now()));
|
|
584
|
+
this.decisions.set(id, { request, resolve, reject, timer, startedAt: this.app.now() });
|
|
585
|
+
this.openTouchpoints += 1;
|
|
586
|
+
this.notify("decision", request);
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
turn(turn, deadline) {
|
|
590
|
+
const id = `t${this.nextId++}`;
|
|
591
|
+
const request = { id, name: turn.name, turn, deadline, content: this.app.content.turn(turn.name) };
|
|
592
|
+
return new Promise((resolve, reject) => {
|
|
593
|
+
const timer = setTimeout(() => this.settleTurn(id, "default", "timeout"), Math.max(0, deadline - this.app.now()));
|
|
594
|
+
this.turns.set(id, { request, resolve, reject, timer, startedAt: this.app.now() });
|
|
595
|
+
this.openTouchpoints += 1;
|
|
596
|
+
this.notify("turn", request);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
/** @internal A program-initiated `install` or `session`; resolves when the harness ends it or detaches. */
|
|
600
|
+
touchpoint(kind) {
|
|
601
|
+
const id = `p${this.nextId++}`;
|
|
602
|
+
const content = kind === "install" ? this.app.content.install() : this.app.content.use();
|
|
603
|
+
const request = { id, kind, content };
|
|
604
|
+
return new Promise((resolve, reject) => {
|
|
605
|
+
const timer = setInterval(() => { }, TOUCHPOINT_KEEPALIVE_MS);
|
|
606
|
+
this.touchpoints.set(id, { request, resolve, reject, timer, startedAt: this.app.now() });
|
|
607
|
+
this.openTouchpoints += 1;
|
|
608
|
+
this.notify("touchpoint", request);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
emitted(seq) {
|
|
612
|
+
const read = this.app.region.readEvents(seq - 1);
|
|
613
|
+
const slot = read.slots.find((s) => s.seq === seq);
|
|
614
|
+
if (slot)
|
|
615
|
+
this.notify("event", this.live(slot));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
//# sourceMappingURL=harness-binding.js.map
|