@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/config.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The app's G9AppPilot configuration at run time (plan §10.1): every capability optional.
|
|
2
|
+
//
|
|
3
|
+
// The build carries the configuration (the app's `pilot` block, per environment); `installPilotConfig(initial)` takes it
|
|
4
|
+
// and a QA controller may change what is SAFE to change on a running app — `pilot.config.get {path?}` (observe),
|
|
5
|
+
// `pilot.config.set {path, value}` (admin) — until the app restarts. Keys, the servers a write may reach and whether
|
|
6
|
+
// DevTool is linked change only in a build: `set` refuses them by name.
|
|
7
|
+
//
|
|
8
|
+
// A capability switched off is refused at the door (one dispatch gate, not a check in every feature):
|
|
9
|
+
// network.shaping → net.shape clock.control → clock.set, clock.reset
|
|
10
|
+
// stage.seed → state.seed studio.tokens → design.set, design.reset
|
|
11
|
+
// oracles.enabled → oracle.* (and the oracles report nothing: setOraclesEnabled)
|
|
12
|
+
// `events.channels.<kind>` switches a heavy event channel (net.body, perf.frame, store.change) on or off.
|
|
13
|
+
import { addGate } from './dispatch.ts';
|
|
14
|
+
import { PilotError } from './errors.ts';
|
|
15
|
+
import { setEventChannel } from './events.ts';
|
|
16
|
+
import { setOraclesEnabled } from './oracles.ts';
|
|
17
|
+
import { registerCommand } from './registry.ts';
|
|
18
|
+
|
|
19
|
+
export interface PilotConfig {
|
|
20
|
+
readonly network?: { readonly shaping?: boolean };
|
|
21
|
+
readonly clock?: { readonly control?: boolean };
|
|
22
|
+
readonly stage?: { readonly seed?: boolean; readonly reduceMotion?: boolean };
|
|
23
|
+
readonly studio?: { readonly tokens?: boolean; readonly inspectorGesture?: boolean };
|
|
24
|
+
readonly oracles?: { readonly enabled?: boolean };
|
|
25
|
+
readonly events?: { readonly channels?: Readonly<Record<string, boolean>> };
|
|
26
|
+
readonly badge?: { readonly mode?: string };
|
|
27
|
+
/** true: a run-time change (pilot.config.set) survives a restart, kept in the app's store (plan v2 P1-23). */
|
|
28
|
+
readonly persist?: boolean;
|
|
29
|
+
readonly [key: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Where the app keeps the run-time changes when `persist` is on: any small string store (a secure store, a file). */
|
|
33
|
+
export interface PilotConfigStore {
|
|
34
|
+
load(): Promise<string | null> | string | null;
|
|
35
|
+
save(text: string): Promise<void> | void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The paths a running app may change; anything else is the build's. */
|
|
39
|
+
const RUNTIME_PATHS = ['network.shaping', 'clock.control', 'stage.seed', 'stage.reduceMotion', 'studio.tokens', 'studio.inspectorGesture', 'oracles.enabled', 'badge.mode'];
|
|
40
|
+
const RUNTIME_PREFIXES = ['events.channels.'];
|
|
41
|
+
|
|
42
|
+
/** Which capability a command belongs to (a command of a capability that is off is refused). */
|
|
43
|
+
function capabilityOf(command: string): string | null {
|
|
44
|
+
if (command === 'net.shape') return 'network.shaping';
|
|
45
|
+
if (command === 'clock.set' || command === 'clock.reset') return 'clock.control';
|
|
46
|
+
if (command === 'state.seed') return 'stage.seed';
|
|
47
|
+
if (command === 'design.set' || command === 'design.reset') return 'studio.tokens';
|
|
48
|
+
if (command.startsWith('oracle.') && command !== 'oracle.rules') return 'oracles.enabled';
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let config: Record<string, unknown> = {};
|
|
53
|
+
|
|
54
|
+
const read = (path: string): unknown => path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as Record<string, unknown>)[k] : undefined), config);
|
|
55
|
+
|
|
56
|
+
/** A capability is on unless the configuration says `false` (everything optional, nothing required). */
|
|
57
|
+
export const pilotConfigOn = (path: string): boolean => read(path) !== false;
|
|
58
|
+
|
|
59
|
+
export const pilotConfigValue = (path: string): unknown => read(path);
|
|
60
|
+
|
|
61
|
+
function write(path: string, value: unknown): void {
|
|
62
|
+
const keys = path.split('.');
|
|
63
|
+
const next = structuredCloneish(config);
|
|
64
|
+
let o = next;
|
|
65
|
+
for (const k of keys.slice(0, -1)) {
|
|
66
|
+
if (!o[k] || typeof o[k] !== 'object') o[k] = {};
|
|
67
|
+
o = o[k] as Record<string, unknown>;
|
|
68
|
+
}
|
|
69
|
+
o[keys[keys.length - 1]!] = value;
|
|
70
|
+
config = next;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function structuredCloneish(v: Record<string, unknown>): Record<string, unknown> {
|
|
74
|
+
return JSON.parse(JSON.stringify(v)) as Record<string, unknown>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Applies what a value means beyond the table (the oracles' switch, a heavy event channel). */
|
|
78
|
+
function apply(path: string): void {
|
|
79
|
+
if (path === 'oracles.enabled') setOraclesEnabled(pilotConfigOn('oracles.enabled'));
|
|
80
|
+
if (path.startsWith('events.channels.')) setEventChannel(path.slice('events.channels.'.length), read(path) === true);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The run-time changes of this session (path → value): what `persist` keeps. */
|
|
84
|
+
let runtimeChanges: Record<string, unknown> = {};
|
|
85
|
+
let persistStore: PilotConfigStore | null = null;
|
|
86
|
+
const runtimeAllowed = (path: string) => RUNTIME_PATHS.includes(path) || RUNTIME_PREFIXES.some((p) => path.startsWith(p) && path.length > p.length);
|
|
87
|
+
const savePersisted = () => {
|
|
88
|
+
try {
|
|
89
|
+
void Promise.resolve(persistStore?.save(JSON.stringify(runtimeChanges))).catch(() => undefined);
|
|
90
|
+
} catch {
|
|
91
|
+
// a store that fails keeps nothing; the change still holds until the app restarts
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Installs the build's configuration, its gate and `pilot.config.get | set | reset`. With `persist: true` in the build and a
|
|
97
|
+
* `store`, the run-time changes saved before are put back (only the paths a running app may change; a build value of a
|
|
98
|
+
* later build is NOT overridden by an older key that is no longer run-time) and every change is saved. Returns the remover.
|
|
99
|
+
*/
|
|
100
|
+
export function installPilotConfig(initial: PilotConfig = {}, { store = null }: { readonly store?: PilotConfigStore | null } = {}): () => void {
|
|
101
|
+
config = structuredCloneish(initial as Record<string, unknown>);
|
|
102
|
+
runtimeChanges = {};
|
|
103
|
+
persistStore = initial.persist === true ? store : null;
|
|
104
|
+
setOraclesEnabled(pilotConfigOn('oracles.enabled'));
|
|
105
|
+
for (const [kind, on] of Object.entries(initial.events?.channels ?? {})) setEventChannel(kind, on === true);
|
|
106
|
+
if (persistStore) {
|
|
107
|
+
const restore = (text: string | null) => {
|
|
108
|
+
let saved: Record<string, unknown> = {};
|
|
109
|
+
try {
|
|
110
|
+
saved = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
111
|
+
} catch {
|
|
112
|
+
saved = {};
|
|
113
|
+
}
|
|
114
|
+
for (const [path, value] of Object.entries(saved)) {
|
|
115
|
+
if (!runtimeAllowed(path)) continue;
|
|
116
|
+
write(path, value);
|
|
117
|
+
runtimeChanges[path] = value;
|
|
118
|
+
apply(path);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
try {
|
|
122
|
+
const loaded = persistStore.load();
|
|
123
|
+
if (loaded && typeof (loaded as Promise<string | null>).then === 'function') void (loaded as Promise<string | null>).then(restore, () => undefined);
|
|
124
|
+
else restore(loaded as string | null);
|
|
125
|
+
} catch {
|
|
126
|
+
// nothing kept
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const offs = [
|
|
130
|
+
addGate((request) => {
|
|
131
|
+
const cap = capabilityOf(request.command);
|
|
132
|
+
if (!cap || pilotConfigOn(cap)) return null;
|
|
133
|
+
return { ok: false, error: `"${request.command}" is off in this app's G9AppPilot configuration (${cap} = false).`, code: 'DISABLED', category: 'automation', hint: `pilot.config.set {path: '${cap}', value: true} turns it on until the app restarts.` };
|
|
134
|
+
}),
|
|
135
|
+
registerCommand(
|
|
136
|
+
'pilot.config.get',
|
|
137
|
+
(args) => {
|
|
138
|
+
const path = typeof args.path === 'string' && args.path ? args.path : null;
|
|
139
|
+
return path ? { path, value: read(path) ?? null, on: pilotConfigOn(path) } : { config, runtime: [...RUNTIME_PATHS, ...RUNTIME_PREFIXES.map((p) => `${p}<kind>`)] };
|
|
140
|
+
},
|
|
141
|
+
{ scope: 'observe', description: 'The G9AppPilot configuration in force (or one path of it), and what may change at run time.' },
|
|
142
|
+
),
|
|
143
|
+
registerCommand(
|
|
144
|
+
'pilot.config.set',
|
|
145
|
+
(args) => {
|
|
146
|
+
const path = typeof args.path === 'string' ? args.path : '';
|
|
147
|
+
if (!RUNTIME_PATHS.includes(path) && !RUNTIME_PREFIXES.some((p) => path.startsWith(p) && path.length > p.length)) {
|
|
148
|
+
throw new PilotError(`"${path}" changes only in a build (the app's pilot configuration); at run time: ${RUNTIME_PATHS.join(', ')}, events.channels.<kind>.`, { code: 'BAD_ARGS', fixExample: { command: 'pilot.config.set', args: { path: 'network.shaping', value: false } } });
|
|
149
|
+
}
|
|
150
|
+
const value = args.value;
|
|
151
|
+
const wantsBool = path !== 'badge.mode';
|
|
152
|
+
if (wantsBool ? typeof value !== 'boolean' : typeof value !== 'string') {
|
|
153
|
+
throw new PilotError(`${path} needs a ${wantsBool ? 'boolean' : 'string'} (got ${JSON.stringify(value)}).`, { code: 'BAD_ARGS' });
|
|
154
|
+
}
|
|
155
|
+
const before = read(path) ?? null;
|
|
156
|
+
write(path, value);
|
|
157
|
+
apply(path);
|
|
158
|
+
runtimeChanges[path] = value;
|
|
159
|
+
if (persistStore) savePersisted();
|
|
160
|
+
return { path, from: before, to: value, until: persistStore ? 'pilot.config.reset (kept across restarts: persist)' : 'the app restarts' };
|
|
161
|
+
},
|
|
162
|
+
{ scope: 'admin', description: 'Switches a safe capability on the running app (until it restarts, or — persist — until pilot.config.reset): network shaping, clock, seed, studio, oracles, heavy event channels.' },
|
|
163
|
+
),
|
|
164
|
+
registerCommand(
|
|
165
|
+
'pilot.config.reset',
|
|
166
|
+
() => {
|
|
167
|
+
const paths = Object.keys(runtimeChanges);
|
|
168
|
+
config = structuredCloneish(initial as Record<string, unknown>);
|
|
169
|
+
runtimeChanges = {};
|
|
170
|
+
for (const p of paths) apply(p);
|
|
171
|
+
if (persistStore) savePersisted();
|
|
172
|
+
return { reset: paths, persist: persistStore !== null };
|
|
173
|
+
},
|
|
174
|
+
{ scope: 'admin', description: 'Every run-time change back to the build’s values (and, with persist, forgotten by the store).' },
|
|
175
|
+
),
|
|
176
|
+
];
|
|
177
|
+
return () => {
|
|
178
|
+
for (const off of offs) off();
|
|
179
|
+
config = {};
|
|
180
|
+
setOraclesEnabled(true);
|
|
181
|
+
};
|
|
182
|
+
}
|
package/src/design.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Live design tokens (plan §7): a controller — the studio, an agent — reads the app's tokens and changes one on the
|
|
2
|
+
// running app; the screen follows at once, nothing is saved, a restart forgets it. «Approve» is the controller's: it
|
|
3
|
+
// takes `design.snapshot` and writes the values into the token files (`g9pilot tokens approve`), never the app.
|
|
4
|
+
//
|
|
5
|
+
// installDesign(registry) the app's tokens behind a small interface (the app owns them; production has no pilot)
|
|
6
|
+
//
|
|
7
|
+
// design.tokens {prefix?, changedOnly?} every token: id, value, default, type, where it is written
|
|
8
|
+
// design.set {id, value} | {values} one or many, checked against the type (design scope)
|
|
9
|
+
// design.reset {id?} one, or every change
|
|
10
|
+
// design.snapshot what changed: {id, from, to, source} — what an approval writes
|
|
11
|
+
import { optionalString } from './args.ts';
|
|
12
|
+
import { emit } from './events.ts';
|
|
13
|
+
import { PilotError } from './errors.ts';
|
|
14
|
+
import { registerCommand } from './registry.ts';
|
|
15
|
+
|
|
16
|
+
export interface DesignToken {
|
|
17
|
+
/** Dotted id (`map.chrome.toolSpacing`). */
|
|
18
|
+
readonly id: string;
|
|
19
|
+
/** The value in force (an override, else the default). */
|
|
20
|
+
readonly value: number | string;
|
|
21
|
+
/** The value the build shipped (from the token files). */
|
|
22
|
+
readonly defaultValue: number | string;
|
|
23
|
+
/** DTCG $type: `dimension`, `number`, `color`, … (null: untyped). */
|
|
24
|
+
readonly type: string | null;
|
|
25
|
+
readonly description?: string | null;
|
|
26
|
+
/** Where the default is written: the token file and the token's path in it. */
|
|
27
|
+
readonly source?: { readonly file: string; readonly path: string } | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The app's side: its tokens and their overrides. */
|
|
31
|
+
export interface DesignRegistry {
|
|
32
|
+
tokens(): readonly DesignToken[];
|
|
33
|
+
set(id: string, value: number | string): void;
|
|
34
|
+
/** One token back to its default, or (no id) every one. */
|
|
35
|
+
reset(id?: string): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NUMERIC = new Set(['dimension', 'number', 'duration', 'fontWeight']);
|
|
39
|
+
|
|
40
|
+
/** A value checked against its token's type: a number for a dimension (dp) or a number, a CSS color string for a color. */
|
|
41
|
+
export function checkTokenValue(token: DesignToken, value: unknown): number | string {
|
|
42
|
+
if (NUMERIC.has(token.type ?? '') || (token.type === null && typeof token.defaultValue === 'number')) {
|
|
43
|
+
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN;
|
|
44
|
+
if (!Number.isFinite(n)) throw new PilotError(`${token.id} is a ${token.type ?? 'number'}: it needs a number (got ${JSON.stringify(value)}).`, { code: 'BAD_ARGS', fixExample: { command: 'design.set', args: { id: token.id, value: token.defaultValue } } });
|
|
45
|
+
if (token.type === 'dimension' && n < 0) throw new PilotError(`${token.id} is a dimension: it cannot be negative.`, { code: 'BAD_ARGS' });
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
if (token.type === 'color') {
|
|
49
|
+
if (typeof value !== 'string' || !/^(#[0-9a-fA-F]{3,8}|rgba?\(.+\)|hsla?\(.+\))$/.test(value.trim())) {
|
|
50
|
+
throw new PilotError(`${token.id} is a color: #RRGGBB, #RRGGBBAA, rgb(…) or hsl(…) (got ${JSON.stringify(value)}).`, { code: 'BAD_ARGS' });
|
|
51
|
+
}
|
|
52
|
+
return value.trim();
|
|
53
|
+
}
|
|
54
|
+
if (typeof value !== 'string' && typeof value !== 'number') throw new PilotError(`${token.id} needs a string or a number.`, { code: 'BAD_ARGS' });
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The design commands over the app's registry. Returns the uninstaller. */
|
|
59
|
+
export function installDesign(registry: DesignRegistry): () => void {
|
|
60
|
+
const find = (id: string): DesignToken => {
|
|
61
|
+
const t = registry.tokens().find((x) => x.id === id);
|
|
62
|
+
if (!t) {
|
|
63
|
+
const near = registry
|
|
64
|
+
.tokens()
|
|
65
|
+
.map((x) => x.id)
|
|
66
|
+
.filter((x) => x.split('.').pop() === id.split('.').pop())
|
|
67
|
+
.slice(0, 3);
|
|
68
|
+
throw new PilotError(`No token ${id}${near.length ? ` — did you mean ${near.join(', ')}?` : ''}`, { code: 'NOT_FOUND', fixExample: { command: 'design.tokens', args: { prefix: id.split('.').slice(0, -1).join('.') } } });
|
|
69
|
+
}
|
|
70
|
+
return t;
|
|
71
|
+
};
|
|
72
|
+
const changes = () =>
|
|
73
|
+
registry
|
|
74
|
+
.tokens()
|
|
75
|
+
.filter((t) => t.value !== t.defaultValue)
|
|
76
|
+
.map((t) => ({ id: t.id, from: t.defaultValue, to: t.value, type: t.type, source: t.source ?? null }));
|
|
77
|
+
const offs = [
|
|
78
|
+
registerCommand(
|
|
79
|
+
'design.tokens',
|
|
80
|
+
(args) => {
|
|
81
|
+
const prefix = optionalString(args, 'prefix');
|
|
82
|
+
const changedOnly = args.changedOnly === true;
|
|
83
|
+
const list = registry.tokens().filter((t) => (!prefix || t.id.startsWith(prefix)) && (!changedOnly || t.value !== t.defaultValue));
|
|
84
|
+
return { tokens: list.map((t) => ({ ...t, changed: t.value !== t.defaultValue })), count: list.length };
|
|
85
|
+
},
|
|
86
|
+
{ scope: 'observe', description: 'The app’s design tokens: id, value in force, default, type and the file they are written in.', example: { prefix: 'map.chrome' } },
|
|
87
|
+
),
|
|
88
|
+
registerCommand(
|
|
89
|
+
'design.set',
|
|
90
|
+
(args) => {
|
|
91
|
+
const values: Record<string, unknown> =
|
|
92
|
+
args.values && typeof args.values === 'object' ? (args.values as Record<string, unknown>) : { [String(args.id ?? '')]: args.value };
|
|
93
|
+
if (Object.keys(values).length === 0 || (Object.keys(values).length === 1 && !Object.keys(values)[0])) {
|
|
94
|
+
throw new PilotError('design.set needs {id, value} or {values: {id: value}}.', { code: 'BAD_ARGS', fixExample: { command: 'design.set', args: { id: 'map.chrome.toolSpacing', value: 16 } } });
|
|
95
|
+
}
|
|
96
|
+
// Every value checked before any is applied: one bad value changes nothing.
|
|
97
|
+
const checked = Object.entries(values).map(([id, v]) => [id, checkTokenValue(find(id), v)] as const);
|
|
98
|
+
for (const [id, v] of checked) registry.set(id, v);
|
|
99
|
+
emit('design.changed', { ids: checked.map(([id]) => id) });
|
|
100
|
+
return { set: Object.fromEntries(checked), changes: changes().length };
|
|
101
|
+
},
|
|
102
|
+
{ scope: 'design', description: 'Changes tokens on the running app (until it restarts): the screen follows at once. Checked against the type first.', example: { id: 'map.chrome.toolSpacing', value: 16 } },
|
|
103
|
+
),
|
|
104
|
+
registerCommand(
|
|
105
|
+
'design.reset',
|
|
106
|
+
(args) => {
|
|
107
|
+
const id = optionalString(args, 'id');
|
|
108
|
+
if (id) find(id);
|
|
109
|
+
registry.reset(id ?? undefined);
|
|
110
|
+
emit('design.changed', { ids: id ? [id] : 'all' });
|
|
111
|
+
return { reset: id ?? 'all', changes: changes().length };
|
|
112
|
+
},
|
|
113
|
+
{ scope: 'design', description: 'One token back to its default, or every change.' },
|
|
114
|
+
),
|
|
115
|
+
registerCommand('design.snapshot', () => ({ changes: changes() }), { scope: 'observe', description: 'What changed against the build: {id, from, to, source} — what an approval writes into the token files.' }),
|
|
116
|
+
];
|
|
117
|
+
return () => {
|
|
118
|
+
for (const off of offs) off();
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/dispatch.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// One request from the native listener → one answer. The order is fixed (pilot/AiGuides/03 «Dispatch»):
|
|
2
|
+
// 1. the app's gates → whatever they refuse with (automation switched off, a lease, a derailed app…), in order —
|
|
3
|
+
// first, because "automation is off on this device" is the answer whatever was asked;
|
|
4
|
+
// 2. unknown command → NOT_FOUND (with the count of known ones and the way to list them);
|
|
5
|
+
// 3. scope → SCOPE (the key may not run this command; the native gate already proved the key);
|
|
6
|
+
// 4. the handler → {ok:true, result} | the failure it threw;
|
|
7
|
+
// 5. the app's after-gates→ may turn a success into a refusal (the action itself derailed the app);
|
|
8
|
+
// 6. the observers → see the answer as it leaves (the black box's trail and its failure traces), never change it.
|
|
9
|
+
import { failureOf, PilotError, type PilotFailure } from './errors.ts';
|
|
10
|
+
import { commandHandler, commandNames, commandScope, type PilotContext } from './registry.ts';
|
|
11
|
+
import { scopeAllows } from './scopes.ts';
|
|
12
|
+
|
|
13
|
+
export interface PilotRequest {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly command: string;
|
|
16
|
+
readonly args?: Record<string, unknown>;
|
|
17
|
+
readonly timeoutMs?: number;
|
|
18
|
+
readonly leaseId?: string | null;
|
|
19
|
+
readonly kid?: string | null;
|
|
20
|
+
/** The signing key's scopes, as the native gate admitted them ('*' for an unsigned request a migration allowed). */
|
|
21
|
+
readonly scopes?: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type PilotAnswer = { readonly ok: true; readonly result: unknown } | PilotFailure;
|
|
25
|
+
|
|
26
|
+
/** A gate: null lets the request through; anything else is the refusal (a message, or a full failure). */
|
|
27
|
+
export type PilotGateHook = (request: PilotRequest, ctx: PilotContext) => string | PilotFailure | null;
|
|
28
|
+
|
|
29
|
+
const before: PilotGateHook[] = [];
|
|
30
|
+
const after: PilotGateHook[] = [];
|
|
31
|
+
|
|
32
|
+
/** An app's own check before every command (leases, a switched-off bridge, a derailed app). Returns the remover. */
|
|
33
|
+
export function addGate(hook: PilotGateHook, when: 'before' | 'after' = 'before'): () => void {
|
|
34
|
+
const list = when === 'before' ? before : after;
|
|
35
|
+
list.push(hook);
|
|
36
|
+
return () => {
|
|
37
|
+
const i = list.indexOf(hook);
|
|
38
|
+
if (i >= 0) list.splice(i, 1);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Sees every answer as it leaves (request, answer, how long it took). An observer that throws is ignored. */
|
|
43
|
+
export type PilotAnswerObserver = (request: PilotRequest, answer: PilotAnswer, ms: number) => void;
|
|
44
|
+
const observers: PilotAnswerObserver[] = [];
|
|
45
|
+
|
|
46
|
+
export function onAnswer(observer: PilotAnswerObserver): () => void {
|
|
47
|
+
observers.push(observer);
|
|
48
|
+
return () => {
|
|
49
|
+
const i = observers.indexOf(observer);
|
|
50
|
+
if (i >= 0) observers.splice(i, 1);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const refusal = (r: string | PilotFailure): PilotFailure =>
|
|
55
|
+
typeof r === 'string' ? { ok: false, error: r, code: 'REFUSED', category: r.startsWith('DERAILED:') ? 'derailed' : 'automation' } : r;
|
|
56
|
+
|
|
57
|
+
export async function dispatch(request: PilotRequest): Promise<PilotAnswer> {
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
const answer = await answerOf(request);
|
|
60
|
+
for (const o of observers.slice()) {
|
|
61
|
+
try {
|
|
62
|
+
o(request, answer, Date.now() - started);
|
|
63
|
+
} catch {
|
|
64
|
+
// an observer never changes the answer
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return answer;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function answerOf(request: PilotRequest): Promise<PilotAnswer> {
|
|
71
|
+
const scopes = request.scopes ?? ['*'];
|
|
72
|
+
const timeoutMs = Math.min(Math.max(request.timeoutMs && request.timeoutMs > 0 ? request.timeoutMs : 30_000, 1_000), 120_000);
|
|
73
|
+
// The host waits exactly timeoutMs for the answer. A handler that used all of it (a wait that ends in «not found»,
|
|
74
|
+
// which then lists what IS on screen) answered after the host had given up, and the controller saw TIMEOUT instead of
|
|
75
|
+
// the answer it can act on. So a handler's own budget keeps a margin for answering: 15 %, between 250 ms and 1.5 s.
|
|
76
|
+
const margin = Math.min(1_500, Math.max(250, Math.round(timeoutMs * 0.15)));
|
|
77
|
+
const ctx: PilotContext = { timeoutMs: timeoutMs - margin, leaseId: request.leaseId ?? null, kid: request.kid ?? null, scopes };
|
|
78
|
+
for (const gate of before) {
|
|
79
|
+
const r = gate(request, ctx);
|
|
80
|
+
if (r) return refusal(r);
|
|
81
|
+
}
|
|
82
|
+
const handler = commandHandler(request.command);
|
|
83
|
+
if (!handler) {
|
|
84
|
+
return failureOf(
|
|
85
|
+
new PilotError(`Unknown command "${request.command}". Call qa.capabilities (or pilot.capabilities) for the list — this build serves ${commandNames().length} page commands.`, {
|
|
86
|
+
code: 'NOT_FOUND',
|
|
87
|
+
hint: 'Call pilot.capabilities and use a name it lists.',
|
|
88
|
+
fixExample: { command: 'pilot.capabilities' },
|
|
89
|
+
}),
|
|
90
|
+
request.command,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const scope = commandScope(request.command);
|
|
94
|
+
if (!scopeAllows(scopes, scope)) {
|
|
95
|
+
return { ok: false, error: `The key "${request.kid ?? '(unsigned)'}" may not run "${request.command}" (scope ${scope}).`, code: 'SCOPE', category: 'scope' };
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const result = await handler(request.args ?? {}, ctx);
|
|
99
|
+
for (const gate of after) {
|
|
100
|
+
const r = gate(request, ctx);
|
|
101
|
+
if (r) return refusal(r);
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, result: result ?? null };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
for (const gate of after) {
|
|
106
|
+
const r = gate(request, ctx);
|
|
107
|
+
if (r) return refusal(r);
|
|
108
|
+
}
|
|
109
|
+
return failureOf(error, request.command);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Errors a command throws. The wire shape (pilot/protocol/PROTOCOL.md «Errors"): {ok:false, error, code, category, hint?,
|
|
2
|
+
// fixExample?}. `hint` is one sentence an agent can act on; `fixExample` a correct call. A plain Error from a command
|
|
3
|
+
// becomes code COMMAND_FAILED, category product (the app misbehaved) — throw a PilotError when the cause is known.
|
|
4
|
+
|
|
5
|
+
export type PilotErrorCategory = 'product' | 'automation' | 'auth' | 'scope' | 'lease' | 'derailed';
|
|
6
|
+
|
|
7
|
+
export interface PilotErrorOptions {
|
|
8
|
+
readonly code?: string;
|
|
9
|
+
readonly category?: PilotErrorCategory;
|
|
10
|
+
readonly hint?: string;
|
|
11
|
+
readonly fixExample?: { readonly command: string; readonly args?: Record<string, unknown> };
|
|
12
|
+
readonly retryable?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class PilotError extends Error {
|
|
16
|
+
readonly code: string;
|
|
17
|
+
readonly category: PilotErrorCategory;
|
|
18
|
+
readonly hint: string | undefined;
|
|
19
|
+
readonly fixExample: PilotErrorOptions['fixExample'] | undefined;
|
|
20
|
+
readonly retryable: boolean;
|
|
21
|
+
|
|
22
|
+
constructor(message: string, options: PilotErrorOptions = {}) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'PilotError';
|
|
25
|
+
this.code = options.code ?? 'REFUSED';
|
|
26
|
+
this.category = options.category ?? 'automation';
|
|
27
|
+
this.hint = options.hint;
|
|
28
|
+
this.fixExample = options.fixExample;
|
|
29
|
+
this.retryable = options.retryable ?? false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PilotFailure {
|
|
34
|
+
readonly ok: false;
|
|
35
|
+
readonly error: string;
|
|
36
|
+
readonly code: string;
|
|
37
|
+
readonly category: PilotErrorCategory;
|
|
38
|
+
readonly hint?: string;
|
|
39
|
+
readonly fixExample?: PilotErrorOptions['fixExample'];
|
|
40
|
+
readonly retryable?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function failureOf(error: unknown, command: string): PilotFailure {
|
|
44
|
+
if (error instanceof PilotError) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
error: error.message,
|
|
48
|
+
code: error.code,
|
|
49
|
+
category: error.category,
|
|
50
|
+
...(error.hint ? { hint: error.hint } : {}),
|
|
51
|
+
...(error.fixExample ? { fixExample: error.fixExample } : {}),
|
|
52
|
+
...(error.retryable ? { retryable: true } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// A thrown error of an older registry (an Error named QaError, say) keeps its message as-is: runners match on it.
|
|
56
|
+
if (error instanceof Error && error.name !== 'Error') return { ok: false, error: error.message, code: 'REFUSED', category: 'automation' };
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
+
return { ok: false, error: `Command "${command}" failed: ${message}`, code: 'COMMAND_FAILED', category: 'product' };
|
|
59
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Page events into the bridge's stream (pilot/protocol/PROTOCOL.md, `GET /pilot/events`). The native side numbers them
|
|
2
|
+
// (`seq`), keeps the flight recorder and serves Server-Sent Events; this side only batches: events emitted in one turn
|
|
3
|
+
// leave together on the next tick, so a burst (a list of layer changes) is one bridge call, and nothing waits on it.
|
|
4
|
+
//
|
|
5
|
+
// emit('toast', { text: 'ذخیره شد', kind: 'success' });
|
|
6
|
+
//
|
|
7
|
+
// A kind is a short dotted name (pilot/protocol/PROTOCOL.md, «Events» in AiGuides/03). Heavy kinds (`net.body`,
|
|
8
|
+
// `perf.frame`, `store.change`) are emitted only when the app switched them on (`setEventChannels`).
|
|
9
|
+
|
|
10
|
+
export interface PilotEvent {
|
|
11
|
+
readonly kind: string;
|
|
12
|
+
readonly data: unknown;
|
|
13
|
+
readonly t: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type EventSink = (batch: readonly PilotEvent[]) => void;
|
|
17
|
+
|
|
18
|
+
let sink: EventSink | null = null;
|
|
19
|
+
let queue: PilotEvent[] = [];
|
|
20
|
+
let scheduled = false;
|
|
21
|
+
const heavy = new Set(['net.body', 'perf.frame', 'store.change', 'log.line']);
|
|
22
|
+
let enabledHeavy = new Set<string>();
|
|
23
|
+
|
|
24
|
+
/** The transport connects the native side (null disconnects: events are then dropped, never queued forever). */
|
|
25
|
+
export function setEventSink(next: EventSink | null): void {
|
|
26
|
+
sink = next;
|
|
27
|
+
if (!next) queue = [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function setEventChannels(channels: Readonly<Record<string, boolean>>): void {
|
|
31
|
+
enabledHeavy = new Set(Object.entries(channels).filter(([, on]) => on).map(([k]) => k));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One heavy channel on or off, the others as they were (log.stream, store.watch). */
|
|
35
|
+
export function setEventChannel(kind: string, on: boolean): void {
|
|
36
|
+
if (on) enabledHeavy.add(kind);
|
|
37
|
+
else enabledHeavy.delete(kind);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const eventChannelOn = (kind: string): boolean => enabledHeavy.has(kind);
|
|
41
|
+
|
|
42
|
+
export const eventsConnected = (): boolean => sink !== null;
|
|
43
|
+
|
|
44
|
+
/** (recorder v2) In-page listeners: every emitted event, whether or not a stream is connected. A throw is ignored. */
|
|
45
|
+
type EventListener = (kind: string, data: unknown, t: number) => void;
|
|
46
|
+
const listeners: EventListener[] = [];
|
|
47
|
+
|
|
48
|
+
export function onEmit(listener: EventListener): () => void {
|
|
49
|
+
listeners.push(listener);
|
|
50
|
+
return () => {
|
|
51
|
+
const i = listeners.indexOf(listener);
|
|
52
|
+
if (i >= 0) listeners.splice(i, 1);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function emit(kind: string, data: unknown = null): void {
|
|
57
|
+
if (listeners.length) {
|
|
58
|
+
const t = Date.now();
|
|
59
|
+
for (const l of listeners.slice()) {
|
|
60
|
+
try {
|
|
61
|
+
l(kind, data, t);
|
|
62
|
+
} catch {
|
|
63
|
+
// a listener never stops an event
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!sink) return;
|
|
68
|
+
if (heavy.has(kind) && !enabledHeavy.has(kind)) return;
|
|
69
|
+
queue.push({ kind, data, t: Date.now() });
|
|
70
|
+
if (queue.length > 200) queue.splice(0, queue.length - 200);
|
|
71
|
+
if (scheduled) return;
|
|
72
|
+
scheduled = true;
|
|
73
|
+
setTimeout(flush, 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function flush(): void {
|
|
77
|
+
scheduled = false;
|
|
78
|
+
if (!sink || queue.length === 0) return;
|
|
79
|
+
const batch = queue;
|
|
80
|
+
queue = [];
|
|
81
|
+
try {
|
|
82
|
+
sink(batch);
|
|
83
|
+
} catch {
|
|
84
|
+
// the stream is best-effort: a failed hand-off never reaches the app
|
|
85
|
+
}
|
|
86
|
+
}
|