@g9tm/app-pilot 0.0.0-stage → 0.1.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 +16 -0
- package/package.json +58 -4
- package/src/args.ts +26 -0
- package/src/blackbox.ts +554 -0
- package/src/config.ts +182 -0
- package/src/design.ts +120 -0
- package/src/dispatch.ts +111 -0
- package/src/errors.ts +59 -0
- package/src/events.ts +86 -0
- package/src/har.ts +148 -0
- package/src/idle.ts +125 -0
- package/src/index.ts +105 -0
- package/src/lease.ts +145 -0
- package/src/oracles.ts +384 -0
- package/src/recorder.ts +382 -0
- package/src/registry.ts +98 -0
- package/src/scopes.ts +30 -0
- package/src/stage.ts +314 -0
- package/src/vitals.ts +196 -0
- package/README.md +0 -3
package/src/har.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Recording and replaying the page's responses (plan v2 P1-05, research R3 — Playwright's routeFromHAR, minimal mode):
|
|
2
|
+
// the app's fetch wrapper hands each finished response here while recording; `net.har {save}` gives a HAR 1.2 file to
|
|
3
|
+
// the controller (it keeps it — never the app); `net.har {replay}` makes the wrapper answer from that file instead of the
|
|
4
|
+
// network. Matching is by method + address (the first unused entry, then they cycle): enough to replay a screen; a POST
|
|
5
|
+
// body is not part of the match (the wrapper's shaping hook sees no body).
|
|
6
|
+
//
|
|
7
|
+
// net.har {record: true} start (clears what was recorded) net.har {record: false} stop
|
|
8
|
+
// net.har {save: true} → {har: {log: {version: '1.2', entries}}}
|
|
9
|
+
// net.har {replay: <har>, notFound: 'fallback' | 'abort'} net.har {replay: null} stop replaying
|
|
10
|
+
import { redactText, redactValue } from './blackbox.ts';
|
|
11
|
+
import { emit } from './events.ts';
|
|
12
|
+
import { PilotError } from './errors.ts';
|
|
13
|
+
import { registerCommand } from './registry.ts';
|
|
14
|
+
|
|
15
|
+
export interface HarResponse {
|
|
16
|
+
readonly status: number;
|
|
17
|
+
readonly mimeType: string;
|
|
18
|
+
readonly text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Entry extends HarResponse {
|
|
22
|
+
readonly method: string;
|
|
23
|
+
readonly url: string;
|
|
24
|
+
readonly at: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MAX_ENTRIES = 300;
|
|
28
|
+
const MAX_TEXT = 256 * 1024;
|
|
29
|
+
|
|
30
|
+
let recording = false;
|
|
31
|
+
let recorded: Entry[] = [];
|
|
32
|
+
let replay: { entries: Entry[]; used: Set<number>; notFound: 'fallback' | 'abort'; served: number; missed: number } | null = null;
|
|
33
|
+
|
|
34
|
+
/** Whether the app's fetch wrapper should read and hand over response bodies now. */
|
|
35
|
+
export function harRecording(): boolean {
|
|
36
|
+
return recording;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One finished response while recording: its body is redacted (tokens, secrets) and capped. */
|
|
40
|
+
export function recordHarEntry(entry: { readonly method: string; readonly url: string; readonly status: number; readonly contentType?: string | null; readonly text: string }): void {
|
|
41
|
+
if (!recording || recorded.length >= MAX_ENTRIES) return;
|
|
42
|
+
recorded.push({
|
|
43
|
+
method: entry.method.toUpperCase(),
|
|
44
|
+
url: redactText(entry.url, 2_000),
|
|
45
|
+
status: entry.status,
|
|
46
|
+
mimeType: entry.contentType ?? 'application/json',
|
|
47
|
+
text: redactBody(entry.text),
|
|
48
|
+
at: new Date().toISOString(),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A JSON body is redacted by its keys (token, password, …: redactValue); anything else by its text (bearer tokens, JWTs). */
|
|
53
|
+
function redactBody(text: string): string {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.stringify(redactValue(JSON.parse(text), 24, MAX_TEXT)).slice(0, MAX_TEXT);
|
|
56
|
+
} catch {
|
|
57
|
+
return redactText(text, MAX_TEXT);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* While replaying: the recorded answer for this request, `'abort'` (not in the file and notFound is abort — fail it as
|
|
63
|
+
* offline), or null (not replaying, or fall back to the network).
|
|
64
|
+
*/
|
|
65
|
+
export function harAnswer(method: string, url: string): HarResponse | 'abort' | null {
|
|
66
|
+
if (!replay) return null;
|
|
67
|
+
const m = method.toUpperCase();
|
|
68
|
+
const matching = replay.entries.map((e, i) => [e, i] as const).filter(([e]) => e.method === m && e.url === url);
|
|
69
|
+
if (matching.length === 0) {
|
|
70
|
+
replay.missed++;
|
|
71
|
+
return replay.notFound === 'abort' ? 'abort' : null;
|
|
72
|
+
}
|
|
73
|
+
// The first one not served yet; when all were, they cycle (a screen asked twice gets the same answers again).
|
|
74
|
+
let pick = matching.find(([, i]) => !replay!.used.has(i));
|
|
75
|
+
if (!pick) {
|
|
76
|
+
for (const [, i] of matching) replay.used.delete(i);
|
|
77
|
+
pick = matching[0]!;
|
|
78
|
+
}
|
|
79
|
+
replay.used.add(pick[1]);
|
|
80
|
+
replay.served++;
|
|
81
|
+
emit('net.shaped', { method: m, url, mode: 'replay' });
|
|
82
|
+
return { status: pick[0].status, mimeType: pick[0].mimeType, text: pick[0].text };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toHar() {
|
|
86
|
+
return {
|
|
87
|
+
log: {
|
|
88
|
+
version: '1.2',
|
|
89
|
+
creator: { name: 'G9AppPilot', version: '1' },
|
|
90
|
+
entries: recorded.map((e) => ({
|
|
91
|
+
startedDateTime: e.at,
|
|
92
|
+
time: 0,
|
|
93
|
+
request: { method: e.method, url: e.url, httpVersion: 'HTTP/1.1', headers: [], queryString: [], cookies: [], headersSize: -1, bodySize: -1 },
|
|
94
|
+
response: { status: e.status, statusText: '', httpVersion: 'HTTP/1.1', headers: [], cookies: [], content: { size: e.text.length, mimeType: e.mimeType, text: e.text }, redirectURL: '', headersSize: -1, bodySize: -1 },
|
|
95
|
+
cache: {},
|
|
96
|
+
timings: { send: 0, wait: 0, receive: 0 },
|
|
97
|
+
})),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function fromHar(value: unknown): Entry[] {
|
|
103
|
+
const entries = (value as { log?: { entries?: unknown } } | null)?.log?.entries;
|
|
104
|
+
if (!Array.isArray(entries)) throw new PilotError('net.har replay needs a HAR file ({log: {entries: [...]}}) — what net.har {save} gave.', { code: 'BAD_ARGS' });
|
|
105
|
+
return entries.map((x, i) => {
|
|
106
|
+
const e = x as { request?: { method?: string; url?: string }; response?: { status?: number; content?: { mimeType?: string; text?: string } } };
|
|
107
|
+
if (!e.request?.url || !e.request.method || typeof e.response?.status !== 'number') throw new PilotError(`entries[${i}] has no request method/url or response status.`, { code: 'BAD_ARGS' });
|
|
108
|
+
return { method: e.request.method.toUpperCase(), url: e.request.url, status: e.response.status, mimeType: e.response.content?.mimeType ?? 'application/json', text: e.response.content?.text ?? '', at: '' };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `net.har` (scope stage). Returns the uninstaller. */
|
|
113
|
+
export function installHar(): () => void {
|
|
114
|
+
return registerCommand(
|
|
115
|
+
'net.har',
|
|
116
|
+
(args) => {
|
|
117
|
+
if (typeof args.record === 'boolean') {
|
|
118
|
+
recording = args.record;
|
|
119
|
+
if (recording) recorded = [];
|
|
120
|
+
return { recording, entries: recorded.length };
|
|
121
|
+
}
|
|
122
|
+
if (args.save === true) return { entries: recorded.length, har: toHar() };
|
|
123
|
+
if ('replay' in args) {
|
|
124
|
+
if (args.replay === null) {
|
|
125
|
+
const was = replay;
|
|
126
|
+
replay = null;
|
|
127
|
+
return { replaying: false, served: was?.served ?? 0, missed: was?.missed ?? 0 };
|
|
128
|
+
}
|
|
129
|
+
const notFound = args.notFound === 'abort' ? 'abort' : 'fallback';
|
|
130
|
+
replay = { entries: fromHar(args.replay), used: new Set(), notFound, served: 0, missed: 0 };
|
|
131
|
+
return { replaying: true, entries: replay.entries.length, notFound };
|
|
132
|
+
}
|
|
133
|
+
return { recording, entries: recorded.length, replaying: replay !== null, served: replay?.served ?? 0, missed: replay?.missed ?? 0 };
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
scope: 'stage',
|
|
137
|
+
description: 'Records the page’s responses ({record: true|false}), gives them as a HAR 1.2 file ({save: true}), or answers from one ({replay: <har>, notFound: fallback|abort}; {replay: null} stops).',
|
|
138
|
+
example: { record: true },
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** For tests. */
|
|
144
|
+
export function resetHarForTests(): void {
|
|
145
|
+
recording = false;
|
|
146
|
+
recorded = [];
|
|
147
|
+
replay = null;
|
|
148
|
+
}
|
package/src/idle.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The app's busy tracker (`ui.waitIdle`, `app.busy`): every in-flight unit of work registers a reason while it runs —
|
|
2
|
+
// HTTP requests (`http:GET /api/items`), sync phases, sheet / popup / page animations (`ui:sheet:open`), start-up steps,
|
|
3
|
+
// explicit scopes. Idle = no reason for `quietForMs` continuously. Always on and cheap (a Map insert/delete per unit), so
|
|
4
|
+
// the code paths an automation measures are the production ones; only the bridge that reads it is test-build-only.
|
|
5
|
+
// Platform-free: the app feeds it (an HTTP client's `busy` option, a host's sheet/popup motion), the bridge reads it.
|
|
6
|
+
|
|
7
|
+
interface BusyEntry {
|
|
8
|
+
readonly reason: string;
|
|
9
|
+
readonly sinceMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const active = new Map<number, BusyEntry>();
|
|
13
|
+
let seq = 0;
|
|
14
|
+
let lastChangeMs = Date.now();
|
|
15
|
+
const listeners = new Set<() => void>();
|
|
16
|
+
|
|
17
|
+
function changed() {
|
|
18
|
+
lastChangeMs = Date.now();
|
|
19
|
+
for (const l of Array.from(listeners)) l();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Marks `reason` busy until the returned function is called (idempotent). */
|
|
23
|
+
export function beginBusy(reason: string): () => void {
|
|
24
|
+
const id = ++seq;
|
|
25
|
+
active.set(id, { reason, sinceMs: Date.now() });
|
|
26
|
+
changed();
|
|
27
|
+
let done = false;
|
|
28
|
+
return () => {
|
|
29
|
+
if (done) return;
|
|
30
|
+
done = true;
|
|
31
|
+
active.delete(id);
|
|
32
|
+
changed();
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Busy while the promise is pending. */
|
|
37
|
+
export function trackBusy<T>(reason: string, promise: Promise<T>): Promise<T> {
|
|
38
|
+
const end = beginBusy(reason);
|
|
39
|
+
return promise.finally(end);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Busy for a fixed time (animations whose end is not observable). */
|
|
43
|
+
export function busyFor(reason: string, ms: number): void {
|
|
44
|
+
const end = beginBusy(reason);
|
|
45
|
+
setTimeout(end, ms);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The tracker in the shape `createHttpClient({ busy })` expects. */
|
|
49
|
+
export const busyTracker = { begin: beginBusy };
|
|
50
|
+
|
|
51
|
+
/** A reason open longer than this is shown as a suspected leak — it still counts. */
|
|
52
|
+
export const BUSY_SUSPECT_MS = 3 * 60_000;
|
|
53
|
+
|
|
54
|
+
export interface BusySnapshot {
|
|
55
|
+
readonly idle: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* `reason (12.3s)` per open reason, oldest first: a name open more than once says how
|
|
58
|
+
* many times (`×2`), one open past BUSY_SUSPECT_MS ends in ` — suspected leak`. Digits are ASCII whatever the app's
|
|
59
|
+
* language — a log reader parses them.
|
|
60
|
+
*/
|
|
61
|
+
readonly reasons: string[];
|
|
62
|
+
/** How long the oldest open reason has been running (0 when idle): a runner tells a working app from a stuck one. */
|
|
63
|
+
readonly oldestMs: number;
|
|
64
|
+
/** ms since the last begin/end. */
|
|
65
|
+
readonly quietMs: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function busySnapshot(nowMs = Date.now()): BusySnapshot {
|
|
69
|
+
const byReason = new Map<string, { since: number; count: number }>();
|
|
70
|
+
for (const e of active.values()) {
|
|
71
|
+
const g = byReason.get(e.reason);
|
|
72
|
+
if (g) {
|
|
73
|
+
g.count++;
|
|
74
|
+
g.since = Math.min(g.since, e.sinceMs);
|
|
75
|
+
} else byReason.set(e.reason, { since: e.sinceMs, count: 1 });
|
|
76
|
+
}
|
|
77
|
+
const groups = [...byReason.entries()].sort((a, b) => a[1].since - b[1].since);
|
|
78
|
+
return {
|
|
79
|
+
idle: groups.length === 0,
|
|
80
|
+
reasons: groups.map(([reason, g]) => {
|
|
81
|
+
const ms = nowMs - g.since;
|
|
82
|
+
return `${reason}${g.count > 1 ? ` ×${g.count}` : ''} (${(ms / 1000).toFixed(1)}s)${ms > BUSY_SUSPECT_MS ? ' — suspected leak' : ''}`;
|
|
83
|
+
}),
|
|
84
|
+
oldestMs: groups.length > 0 ? nowMs - (groups[0] as [string, { since: number }])[1].since : 0,
|
|
85
|
+
quietMs: nowMs - lastChangeMs,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolves when nothing has been busy for `quietForMs` continuously; rejects after `timeoutMs` with
|
|
91
|
+
* "Wait for idle timed out after 30s. Still busy: …" — the wording runners classify as an automation failure.
|
|
92
|
+
*/
|
|
93
|
+
export function waitIdle(quietForMs: number, timeoutMs: number): Promise<void> {
|
|
94
|
+
const started = Date.now();
|
|
95
|
+
return new Promise<void>((resolve, reject) => {
|
|
96
|
+
const tick = () => {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const snap = busySnapshot(now);
|
|
99
|
+
if (snap.idle && snap.quietMs >= quietForMs) {
|
|
100
|
+
resolve();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (now - started >= timeoutMs) {
|
|
104
|
+
const secs = Math.round(timeoutMs / 1000);
|
|
105
|
+
reject(
|
|
106
|
+
new Error(
|
|
107
|
+
snap.idle
|
|
108
|
+
? `Wait for idle timed out after ${secs}s. The app kept changing state (quiet only ${snap.quietMs} ms).`
|
|
109
|
+
: `Wait for idle timed out after ${secs}s. Still busy: ${snap.reasons.join(', ')}.`,
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
setTimeout(tick, 40);
|
|
115
|
+
};
|
|
116
|
+
tick();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function subscribeBusy(listener: () => void): () => void {
|
|
121
|
+
listeners.add(listener);
|
|
122
|
+
return () => {
|
|
123
|
+
listeners.delete(listener);
|
|
124
|
+
};
|
|
125
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @g9tm/app-pilot — the platform-free core of G9AppPilot: the command registry and its manifest, scopes, the dispatch
|
|
2
|
+
// order, actionable errors, the page's event batching, the busy tracker, the lease, the tap recorder, the black box (logs, requests, stores, database, failure traces) and the always-awake oracles. No Lynx, no
|
|
3
|
+
// native code: @g9tm/app-pilot-lynx connects it to a Lynx host. pilot/AiGuides/00 is the map.
|
|
4
|
+
export { optionalBool, optionalInt, optionalString, requireString } from './args.ts';
|
|
5
|
+
export { addGate, dispatch, onAnswer, type PilotAnswer, type PilotAnswerObserver, type PilotGateHook, type PilotRequest } from './dispatch.ts';
|
|
6
|
+
export { failureOf, PilotError, type PilotErrorCategory, type PilotErrorOptions, type PilotFailure } from './errors.ts';
|
|
7
|
+
export { emit, eventChannelOn, eventsConnected, flush, type EventSink, onEmit, type PilotEvent, setEventChannel, setEventChannels, setEventSink } from './events.ts';
|
|
8
|
+
export {
|
|
9
|
+
type CommandMeta,
|
|
10
|
+
commandHandler,
|
|
11
|
+
commandNames,
|
|
12
|
+
commandScope,
|
|
13
|
+
manifest,
|
|
14
|
+
type ManifestEntry,
|
|
15
|
+
onCommandsChanged,
|
|
16
|
+
type PilotArgs,
|
|
17
|
+
type PilotContext,
|
|
18
|
+
type PilotHandler,
|
|
19
|
+
registerCommand,
|
|
20
|
+
resetRegistryForTests,
|
|
21
|
+
} from './registry.ts';
|
|
22
|
+
export { defaultScope, type PilotScope, SCOPES, scopeAllows } from './scopes.ts';
|
|
23
|
+
export {
|
|
24
|
+
beginBusy,
|
|
25
|
+
BUSY_SUSPECT_MS,
|
|
26
|
+
busyFor,
|
|
27
|
+
type BusySnapshot,
|
|
28
|
+
busySnapshot,
|
|
29
|
+
busyTracker,
|
|
30
|
+
subscribeBusy,
|
|
31
|
+
trackBusy,
|
|
32
|
+
waitIdle,
|
|
33
|
+
} from './idle.ts';
|
|
34
|
+
export { installLease, type InstallLeaseOptions, LEASE_RENEW_MS, leaseGate, leaseHolder, resetLeaseForTests } from './lease.ts';
|
|
35
|
+
export {
|
|
36
|
+
CANDIDATE_GAP_MS,
|
|
37
|
+
FIELD_TYPES,
|
|
38
|
+
flowStepOf,
|
|
39
|
+
MAX_STEPS,
|
|
40
|
+
PASSIVE_CAPACITY,
|
|
41
|
+
PilotRecorder,
|
|
42
|
+
pilotRecorder,
|
|
43
|
+
type RecordedKind,
|
|
44
|
+
type RecordedStep,
|
|
45
|
+
recordCommand,
|
|
46
|
+
type RecorderPort,
|
|
47
|
+
recordEvent,
|
|
48
|
+
registerRecorderCommands,
|
|
49
|
+
SAME_TAP_MS,
|
|
50
|
+
SUGGEST_WITHIN_MS,
|
|
51
|
+
type SuggestedCheck,
|
|
52
|
+
} from './recorder.ts';
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
type BlackboxOptions,
|
|
56
|
+
captureTrace,
|
|
57
|
+
installBlackbox,
|
|
58
|
+
type LogEntry,
|
|
59
|
+
type LogLevel,
|
|
60
|
+
type NetEntry,
|
|
61
|
+
type PilotTrace,
|
|
62
|
+
onLogRecorded,
|
|
63
|
+
recordLog,
|
|
64
|
+
recordNet,
|
|
65
|
+
redactText,
|
|
66
|
+
redactValue,
|
|
67
|
+
registerDbQuery,
|
|
68
|
+
registerStore,
|
|
69
|
+
registerTraceSection,
|
|
70
|
+
resetBlackboxForTests,
|
|
71
|
+
Ring,
|
|
72
|
+
type TrailEntry,
|
|
73
|
+
} from './blackbox.ts';
|
|
74
|
+
|
|
75
|
+
export {
|
|
76
|
+
BUSY_LEAK_MS,
|
|
77
|
+
checkScreen,
|
|
78
|
+
DEFAULT_ORACLE_RULES,
|
|
79
|
+
installOracles,
|
|
80
|
+
latinDigitsInPersian,
|
|
81
|
+
looksLikeKey,
|
|
82
|
+
offScreenControls,
|
|
83
|
+
type OracleOptions,
|
|
84
|
+
type OracleSeverity,
|
|
85
|
+
type OracleViolation,
|
|
86
|
+
rawKeysOnScreen,
|
|
87
|
+
registerScreenCheck,
|
|
88
|
+
reportViolation,
|
|
89
|
+
resetOraclesForTests,
|
|
90
|
+
type ScreenCheck,
|
|
91
|
+
type ScreenContext,
|
|
92
|
+
type ScreenFinding,
|
|
93
|
+
type ScreenNode,
|
|
94
|
+
unlabeledControls,
|
|
95
|
+
} from './oracles.ts';
|
|
96
|
+
|
|
97
|
+
export { checkTokenValue, type DesignRegistry, type DesignToken, installDesign } from './design.ts';
|
|
98
|
+
|
|
99
|
+
export { harRecording, recordHarEntry } from './har.ts';
|
|
100
|
+
export { createVitalsWatch, heartbeatClock, installVitals, type VitalsOptions, type VitalsReading, type VitalsStall } from './vitals.ts';
|
|
101
|
+
export { installStage, onPilotFlag, onSocketDrop, pilotFlag, resetStageForTests, type Shape, type ShapeMode, type ShapeRule, shapeRequest, shapeSocket } from './stage.ts';
|
|
102
|
+
|
|
103
|
+
export const PILOT_PROTOCOL = 2;
|
|
104
|
+
export const PILOT_VERSION = '0.1.0';
|
|
105
|
+
export { installPilotConfig, type PilotConfig, type PilotConfigStore, pilotConfigOn, pilotConfigValue } from './config.ts';
|
package/src/lease.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The lease: one controller drives the device at a time. `lease.acquire {owner, ttlSeconds}` returns a lease id; while a
|
|
2
|
+
// lease is held, every command must carry it (the request's `leaseId`) or it is refused — never interleaved into somebody
|
|
3
|
+
// else's run. A command with the valid id renews the lease; an unused one expires by itself.
|
|
4
|
+
//
|
|
5
|
+
// installLease() registers lease.acquire / release / status and the gate (once; returns the uninstall). The
|
|
6
|
+
// gate joins the others in the order it is installed: add an earlier gate first.
|
|
7
|
+
// leaseGate(cmd, id) the gate itself, for a host that orders its own gates
|
|
8
|
+
//
|
|
9
|
+
// A PURE READ passes without the lease (plan v2 P2-15): a registered observe command not declared mutating, minus the
|
|
10
|
+
// HEAVY ones that hold the device's threads or change a counter (a screenshot, the screen model, a trace, a raw CDP
|
|
11
|
+
// call). Reading moves nothing in somebody else's run; a watcher (a live view, an agent's perf.vitals) is not
|
|
12
|
+
// refused while a runner holds the device.
|
|
13
|
+
import { addGate } from './dispatch.ts';
|
|
14
|
+
import { PilotError } from './errors.ts';
|
|
15
|
+
import { optionalInt, optionalString } from './args.ts';
|
|
16
|
+
import { isPureRead, registerCommand } from './registry.ts';
|
|
17
|
+
|
|
18
|
+
interface Lease {
|
|
19
|
+
readonly leaseId: string;
|
|
20
|
+
readonly owner: string;
|
|
21
|
+
expiresAtMs: number;
|
|
22
|
+
commandsServed: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let lease: Lease | null = null;
|
|
26
|
+
|
|
27
|
+
/** A command with the valid lease id keeps the lease for this long again. */
|
|
28
|
+
export const LEASE_RENEW_MS = 5 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
const FREE_COMMANDS: ReadonlySet<string> = new Set(['lease.acquire', 'lease.status']);
|
|
31
|
+
/** Observe commands that still need the lease: they take the device's time or reset what they read. */
|
|
32
|
+
export const LEASED_READS: ReadonlySet<string> = new Set([
|
|
33
|
+
'ui.screenshot',
|
|
34
|
+
'screen.capture',
|
|
35
|
+
'screen.model',
|
|
36
|
+
'trace.capture',
|
|
37
|
+
'devtool.cdp',
|
|
38
|
+
'devtool.probe',
|
|
39
|
+
'perf.frameStats',
|
|
40
|
+
]);
|
|
41
|
+
const freeWithoutLease = (command: string) => FREE_COMMANDS.has(command) || (isPureRead(command) && !LEASED_READS.has(command));
|
|
42
|
+
|
|
43
|
+
function newLeaseId(): string {
|
|
44
|
+
let s = '';
|
|
45
|
+
for (let i = 0; i < 4; i++) s += Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, '0');
|
|
46
|
+
return s;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Null when [command] may run under the current lease (renewing it), else the refusal. Lazily expired. */
|
|
50
|
+
export function leaseGate(command: string, leaseId: string | null): string | null {
|
|
51
|
+
if (lease && Date.now() > lease.expiresAtMs) lease = null;
|
|
52
|
+
if (!lease || freeWithoutLease(command)) {
|
|
53
|
+
if (lease && leaseId === lease.leaseId) {
|
|
54
|
+
lease.expiresAtMs = Date.now() + LEASE_RENEW_MS;
|
|
55
|
+
lease.commandsServed++;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (leaseId !== lease.leaseId) {
|
|
60
|
+
return `The device is leased to "${lease.owner}" and this command carries no lease id. Command "${command}" was refused rather than interleaved into somebody else's run.`;
|
|
61
|
+
}
|
|
62
|
+
lease.expiresAtMs = Date.now() + LEASE_RENEW_MS;
|
|
63
|
+
lease.commandsServed++;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Who holds the device now, or null. */
|
|
68
|
+
export function leaseHolder(): { readonly owner: string; readonly expiresInSeconds: number } | null {
|
|
69
|
+
if (!lease || Date.now() > lease.expiresAtMs) return null;
|
|
70
|
+
return { owner: lease.owner, expiresInSeconds: Math.round((lease.expiresAtMs - Date.now()) / 1000) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface InstallLeaseOptions {
|
|
74
|
+
/** Also add the gate (default true). False: the host calls [leaseGate] from its own gate, in its own order. */
|
|
75
|
+
readonly gate?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let installed: (() => void) | null = null;
|
|
79
|
+
|
|
80
|
+
export function installLease(options: InstallLeaseOptions = {}): () => void {
|
|
81
|
+
if (installed) return installed;
|
|
82
|
+
const offs = [
|
|
83
|
+
registerCommand(
|
|
84
|
+
'lease.acquire',
|
|
85
|
+
(args) => {
|
|
86
|
+
const owner = optionalString(args, 'owner') ?? 'runner';
|
|
87
|
+
const ttl = Math.min(Math.max(optionalInt(args, 'ttlSeconds', 300), 1), 1800);
|
|
88
|
+
if (lease && Date.now() <= lease.expiresAtMs && lease.owner !== owner) {
|
|
89
|
+
const secs = Math.round((lease.expiresAtMs - Date.now()) / 1000);
|
|
90
|
+
throw new PilotError(`The device is already leased to "${lease.owner}" for another ${secs}s. Wait, or ask them to release it.`, {
|
|
91
|
+
code: 'LEASE_HELD',
|
|
92
|
+
category: 'lease',
|
|
93
|
+
hint: 'lease.status says who holds it and for how long.',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
lease = { leaseId: newLeaseId(), owner, expiresAtMs: Date.now() + ttl * 1000, commandsServed: 0 };
|
|
97
|
+
return { leaseId: lease.leaseId, owner, ttlSeconds: ttl };
|
|
98
|
+
},
|
|
99
|
+
{ scope: 'drive', mutating: false, description: 'Takes the device for one controller.', example: { owner: 'runner', ttlSeconds: 300 } },
|
|
100
|
+
),
|
|
101
|
+
registerCommand(
|
|
102
|
+
'lease.release',
|
|
103
|
+
(args, ctx) => {
|
|
104
|
+
const id = optionalString(args, 'leaseId') ?? ctx.leaseId;
|
|
105
|
+
if (!lease) return { released: false, note: 'No lease was held.' };
|
|
106
|
+
if (id !== lease.leaseId) {
|
|
107
|
+
throw new PilotError(`The lease is held by "${lease.owner}"; a different lease id cannot release it.`, { code: 'LEASE_HELD', category: 'lease' });
|
|
108
|
+
}
|
|
109
|
+
const served = lease.commandsServed;
|
|
110
|
+
const owner = lease.owner;
|
|
111
|
+
lease = null;
|
|
112
|
+
return { released: true, owner, commandsServed: served };
|
|
113
|
+
},
|
|
114
|
+
{ scope: 'drive', mutating: false, description: 'Gives the device back.' },
|
|
115
|
+
),
|
|
116
|
+
registerCommand(
|
|
117
|
+
'lease.status',
|
|
118
|
+
() =>
|
|
119
|
+
lease && Date.now() <= lease.expiresAtMs
|
|
120
|
+
? { held: true, leaseId: lease.leaseId, owner: lease.owner, expiresInSeconds: Math.round((lease.expiresAtMs - Date.now()) / 1000), commandsServed: lease.commandsServed }
|
|
121
|
+
: { held: false },
|
|
122
|
+
{ scope: 'observe', description: 'Who holds the device, if anybody.' },
|
|
123
|
+
),
|
|
124
|
+
];
|
|
125
|
+
const offGate =
|
|
126
|
+
options.gate === false
|
|
127
|
+
? () => undefined
|
|
128
|
+
: addGate((request) => {
|
|
129
|
+
// lease.release may name its lease in its arguments instead of the request (the id it is giving back).
|
|
130
|
+
const argId = request.command === 'lease.release' && typeof request.args?.['leaseId'] === 'string' ? (request.args['leaseId'] as string) : null;
|
|
131
|
+
const refused = leaseGate(request.command, request.leaseId ?? argId);
|
|
132
|
+
return refused ? { ok: false, error: refused, code: 'LEASE', category: 'lease', hint: 'Send the leaseId lease.acquire returned, or lease.status to see who holds it.' } : null;
|
|
133
|
+
});
|
|
134
|
+
installed = () => {
|
|
135
|
+
offs.forEach((off) => off());
|
|
136
|
+
offGate();
|
|
137
|
+
installed = null;
|
|
138
|
+
};
|
|
139
|
+
return installed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Tests: forget the lease. */
|
|
143
|
+
export function resetLeaseForTests(): void {
|
|
144
|
+
lease = null;
|
|
145
|
+
}
|