@hraness/slopcamera 3.2.7 → 3.2.8
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/NOTICE.md +1 -7
- package/PRIVACY.md +16 -7
- package/README.md +37 -12
- package/apps/desktop/README.md +14 -130
- package/apps/desktop/application/context.ts +0 -9
- package/apps/desktop/application/default-registry.ts +0 -10
- package/apps/desktop/application/operation.ts +1 -5
- package/apps/desktop/application/operations/index.ts +0 -1
- package/apps/desktop/cli/args.ts +14 -86
- package/apps/desktop/cli/command-host-resources.ts +2 -1
- package/apps/desktop/cli/commands.ts +28 -67
- package/apps/desktop/cli/help.ts +12 -18
- package/apps/desktop/cli/html-scene.ts +139 -28
- package/apps/desktop/cli/io.ts +2 -1
- package/apps/desktop/cli/main.ts +23 -47
- package/apps/desktop/cli/menubar.ts +244 -0
- package/apps/desktop/cli/music-analysis-service.ts +61 -0
- package/apps/desktop/cli/portable-surface.ts +12 -4
- package/apps/desktop/code/application-node-planner.ts +0 -47
- package/apps/desktop/code/host-source-layout.ts +46 -0
- package/apps/desktop/code/public.ts +0 -1
- package/apps/desktop/code/runtime-identity.ts +50 -8
- package/apps/desktop/code/semantic-builder.ts +0 -43
- package/apps/desktop/code/source-bundle.ts +23 -6
- package/apps/desktop/code/source-typecheck.ts +131 -43
- package/apps/desktop/code/worker-client.ts +14 -2
- package/apps/desktop/core/music-analysis.ts +124 -1
- package/apps/desktop/dist/cli/main.js +244 -250
- package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
- package/apps/desktop/html-overlay/index.ts +9 -0
- package/apps/desktop/html-overlay/runtime.ts +2 -0
- package/apps/desktop/html-overlay/scene.ts +29 -0
- package/dist/cli.js +83 -81
- package/dist/{index-rvghzt7b.js → index-7131pg1c.js} +43 -236
- package/dist/{index-mcy8z0br.js → index-fava6pge.js} +1 -1
- package/dist/{index-z7239b4h.js → index-h1k0fnjq.js} +1 -1
- package/dist/{index-p63wavx0.js → index-zfnddgay.js} +1 -1
- package/dist/index.js +4 -18
- package/dist/operations.js +2 -2
- package/dist/vectorize/worker.js +1 -1
- package/dist/workflow.js +3 -3
- package/docs/README.md +1 -1
- package/docs/studio.md +1 -1
- package/package.json +6 -21
- package/skills/slopcamera/SKILL.md +8 -0
- package/skills/slopcamera/references/diagrams.md +5 -14
- package/skills/slopcamera/references/install.md +1 -1
- package/skills/slopcamera/references/music-video.md +23 -3
- package/skills/slopcamera/references/reference-led-3d.md +1 -1
- package/skills/slopcamera/references/support.md +25 -0
- package/skills/slopcamera/references/web-media-excerpts.md +120 -0
- package/src/cli.ts +28 -98
- package/src/index.ts +0 -23
- package/src/support-completion.ts +16 -0
- package/src/support.ts +42 -0
- package/src/version.ts +1 -1
- package/apps/desktop/application/operations/recording/index.ts +0 -5
- package/apps/desktop/application/operations/recording/pause.ts +0 -34
- package/apps/desktop/application/operations/recording/resume.ts +0 -34
- package/apps/desktop/application/operations/recording/shared.ts +0 -199
- package/apps/desktop/application/operations/recording/start.ts +0 -169
- package/apps/desktop/application/operations/recording/stop.ts +0 -34
- package/apps/desktop/capture/protocol.ts +0 -689
- package/apps/desktop/cli/capture-bundle.ts +0 -1117
- package/apps/desktop/cli/recording-controller.ts +0 -1567
- package/apps/desktop/cli/recording-daemon.ts +0 -931
- package/src/desktop.ts +0 -253
|
@@ -1,1567 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
CAPTURE_PROTOCOL_VERSION,
|
|
6
|
-
CaptureDeviceSelectionSchema,
|
|
7
|
-
CaptureDisplaySelectionSchema,
|
|
8
|
-
CaptureInterruptionSchema,
|
|
9
|
-
CapturePermissionsSchema,
|
|
10
|
-
CaptureSourceInventorySchema,
|
|
11
|
-
encodeCaptureRequest,
|
|
12
|
-
parseCaptureEventLine,
|
|
13
|
-
TypedTextFocusIdentitiesSchema,
|
|
14
|
-
type CaptureEvent,
|
|
15
|
-
type CaptureInterruption,
|
|
16
|
-
type CaptureOptions,
|
|
17
|
-
type CapturePermissions,
|
|
18
|
-
type CaptureRequest,
|
|
19
|
-
type CaptureSourceInventory,
|
|
20
|
-
type SegmentCompletion,
|
|
21
|
-
} from "../capture/protocol";
|
|
22
|
-
import { asCliError, CliError, errorMessage } from "./errors";
|
|
23
|
-
import { environmentWithoutGatewayCredentials, type CliIo } from "./io";
|
|
24
|
-
import { CaptureBundleWriter, CaptureMediaVerifier } from "./capture-bundle";
|
|
25
|
-
import type { ProcessRunner } from "./io";
|
|
26
|
-
import { ensurePrivateDirectory } from "./paths";
|
|
27
|
-
|
|
28
|
-
export type RecordingDeviceSelection =
|
|
29
|
-
| { readonly kind: "disabled" }
|
|
30
|
-
| { readonly kind: "default" }
|
|
31
|
-
| { readonly deviceId: string; readonly kind: "device" };
|
|
32
|
-
|
|
33
|
-
export type RecordingDisplaySelection =
|
|
34
|
-
| { readonly kind: "all" }
|
|
35
|
-
| { readonly displayIds: readonly string[]; readonly kind: "selected" };
|
|
36
|
-
|
|
37
|
-
export interface RecordingStartOptions {
|
|
38
|
-
readonly camera: RecordingDeviceSelection;
|
|
39
|
-
readonly displays: RecordingDisplaySelection;
|
|
40
|
-
readonly interactionEventProcessIdentifier?: number | null;
|
|
41
|
-
readonly microphone: RecordingDeviceSelection;
|
|
42
|
-
readonly strictInputs: boolean;
|
|
43
|
-
readonly systemAudio: boolean;
|
|
44
|
-
readonly typedText: boolean;
|
|
45
|
-
readonly typedTextFocusIdentities?: CaptureOptions["typedTextFocusIdentities"];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface EffectiveRecordingConfig extends RecordingStartOptions {
|
|
49
|
-
readonly metadata: true;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export type RecordingPhase = "idle" | "recording" | "paused";
|
|
53
|
-
|
|
54
|
-
export interface RecordingSnapshot {
|
|
55
|
-
readonly availableSources: CaptureSourceInventory;
|
|
56
|
-
readonly completedSegmentCount: number;
|
|
57
|
-
readonly effectiveConfig: EffectiveRecordingConfig | null;
|
|
58
|
-
readonly lastInterruption: CaptureInterruption | null;
|
|
59
|
-
readonly logicalTimeUs: number;
|
|
60
|
-
readonly permissions: CapturePermissions | null;
|
|
61
|
-
readonly recordingId: string | null;
|
|
62
|
-
readonly recordingRoot: string | null;
|
|
63
|
-
readonly sources: CaptureSourceInventory;
|
|
64
|
-
readonly state: RecordingPhase;
|
|
65
|
-
readonly updatedAt: string;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface RecordingController {
|
|
69
|
-
close(): Promise<void>;
|
|
70
|
-
pause(): Promise<RecordingSnapshot>;
|
|
71
|
-
resume(): Promise<RecordingSnapshot>;
|
|
72
|
-
start(options: RecordingStartOptions): Promise<RecordingSnapshot>;
|
|
73
|
-
status(): Promise<RecordingSnapshot>;
|
|
74
|
-
stop(): Promise<RecordingSnapshot>;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export interface CaptureTransport {
|
|
78
|
-
close(): Promise<void>;
|
|
79
|
-
readLine(timeoutMs: number): Promise<string>;
|
|
80
|
-
stderrTail(): string;
|
|
81
|
-
write(value: string): Promise<void>;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export interface CaptureTransportFactory {
|
|
85
|
-
spawn(executable: string): Promise<CaptureTransport>;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
type CaptureResponseEventName = Exclude<CaptureEvent["event"], "error" | "ready">;
|
|
89
|
-
type CaptureResponseSequence = readonly CaptureResponseEventName[];
|
|
90
|
-
|
|
91
|
-
const CAPTURE_RESPONSE_SEQUENCES: Readonly<
|
|
92
|
-
Record<CaptureRequest["command"], readonly CaptureResponseSequence[]>
|
|
93
|
-
> = {
|
|
94
|
-
configure: [["configured"]],
|
|
95
|
-
pause: [["segment-completed"]],
|
|
96
|
-
resume: [
|
|
97
|
-
["segment-started"],
|
|
98
|
-
["segment-completed", "segment-started"],
|
|
99
|
-
],
|
|
100
|
-
shutdown: [
|
|
101
|
-
["shutdown"],
|
|
102
|
-
["session-completed", "shutdown"],
|
|
103
|
-
["segment-completed", "session-completed", "shutdown"],
|
|
104
|
-
],
|
|
105
|
-
snapshot: [
|
|
106
|
-
["status"],
|
|
107
|
-
["segment-completed", "status"],
|
|
108
|
-
["session-completed", "status"],
|
|
109
|
-
["segment-completed", "session-completed", "status"],
|
|
110
|
-
],
|
|
111
|
-
start: [["segment-started"]],
|
|
112
|
-
status: [
|
|
113
|
-
["status"],
|
|
114
|
-
["segment-completed", "status"],
|
|
115
|
-
["session-completed", "status"],
|
|
116
|
-
["segment-completed", "session-completed", "status"],
|
|
117
|
-
],
|
|
118
|
-
stop: [
|
|
119
|
-
["session-completed"],
|
|
120
|
-
["segment-completed", "session-completed"],
|
|
121
|
-
],
|
|
122
|
-
};
|
|
123
|
-
const MAX_FAILURE_RECOVERY_SOURCES_BYTES = 24 * 1024;
|
|
124
|
-
|
|
125
|
-
class NonFatalCaptureCommandError extends CliError {
|
|
126
|
-
constructor(
|
|
127
|
-
code: "conflict" | "unavailable",
|
|
128
|
-
event: Extract<CaptureEvent, { readonly event: "error" }>,
|
|
129
|
-
) {
|
|
130
|
-
super(
|
|
131
|
-
code,
|
|
132
|
-
`Capture helper ${event.code}: ${event.message}`,
|
|
133
|
-
{ helperCode: event.code, recoverable: true, state: event.state },
|
|
134
|
-
);
|
|
135
|
-
this.name = "NonFatalCaptureCommandError";
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
interface CaptureResponseFrontier {
|
|
140
|
-
readonly completedSegmentCount: number;
|
|
141
|
-
readonly lastInterruption: CaptureInterruption | null;
|
|
142
|
-
readonly logicalTimeUs: number;
|
|
143
|
-
readonly priorSnapshotLogicalTimeUs: number;
|
|
144
|
-
readonly sessionCompletionPersisted: boolean;
|
|
145
|
-
readonly sources: CaptureSourceInventory;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function isCaptureResponsePrefix(
|
|
149
|
-
sequence: CaptureResponseSequence,
|
|
150
|
-
events: readonly CaptureEvent[],
|
|
151
|
-
): boolean {
|
|
152
|
-
return events.every((event, index) => sequence[index] === event.event);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function captureResponseNames(events: readonly CaptureEvent[]): string {
|
|
156
|
-
return events.map(({ event }) => event).join(" -> ");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function invalidCaptureResponse(
|
|
160
|
-
request: CaptureRequest,
|
|
161
|
-
events: readonly CaptureEvent[],
|
|
162
|
-
detail?: string,
|
|
163
|
-
): CliError {
|
|
164
|
-
const suffix = detail === undefined ? "" : ` ${detail}`;
|
|
165
|
-
return new CliError(
|
|
166
|
-
"invalid-data",
|
|
167
|
-
`Capture helper emitted an invalid ${request.command} response sequence: ${captureResponseNames(events)}.${suffix}`,
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function segmentCompletionIn(
|
|
172
|
-
events: readonly CaptureEvent[],
|
|
173
|
-
): Extract<CaptureEvent, { readonly event: "segment-completed" }> | undefined {
|
|
174
|
-
return events.find((
|
|
175
|
-
event,
|
|
176
|
-
): event is Extract<CaptureEvent, { readonly event: "segment-completed" }> => (
|
|
177
|
-
event.event === "segment-completed"
|
|
178
|
-
));
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function sessionCompletionIn(
|
|
182
|
-
events: readonly CaptureEvent[],
|
|
183
|
-
): Extract<CaptureEvent, { readonly event: "session-completed" }> | undefined {
|
|
184
|
-
return events.find((
|
|
185
|
-
event,
|
|
186
|
-
): event is Extract<CaptureEvent, { readonly event: "session-completed" }> => (
|
|
187
|
-
event.event === "session-completed"
|
|
188
|
-
));
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function sameCaptureInterruption(
|
|
192
|
-
left: CaptureInterruption | null,
|
|
193
|
-
right: CaptureInterruption | null,
|
|
194
|
-
): boolean {
|
|
195
|
-
if (left === null || right === null) return left === right;
|
|
196
|
-
return left.code === right.code
|
|
197
|
-
&& left.nativeTimeUs === right.nativeTimeUs
|
|
198
|
-
&& left.recoverable === right.recoverable
|
|
199
|
-
&& left.segmentIndex === right.segmentIndex
|
|
200
|
-
&& left.source === right.source
|
|
201
|
-
&& left.sourceId === right.sourceId
|
|
202
|
-
&& left.sourceTimeUs === right.sourceTimeUs;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function sameCaptureSourceInventory(
|
|
206
|
-
left: CaptureSourceInventory,
|
|
207
|
-
right: CaptureSourceInventory,
|
|
208
|
-
): boolean {
|
|
209
|
-
return JSON.stringify(left) === JSON.stringify(right);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function selectedSourcesIn(
|
|
213
|
-
event: CaptureEvent,
|
|
214
|
-
): CaptureSourceInventory | undefined {
|
|
215
|
-
if (
|
|
216
|
-
event.event === "configured"
|
|
217
|
-
|| event.event === "segment-started"
|
|
218
|
-
|| event.event === "status"
|
|
219
|
-
) {
|
|
220
|
-
return event.sources;
|
|
221
|
-
}
|
|
222
|
-
return event.event === "segment-completed"
|
|
223
|
-
? event.segment.sources
|
|
224
|
-
: undefined;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function validateCaptureError(
|
|
228
|
-
request: CaptureRequest,
|
|
229
|
-
response: readonly CaptureEvent[],
|
|
230
|
-
error: Extract<CaptureEvent, { readonly event: "error" }>,
|
|
231
|
-
frontier: CaptureResponseFrontier,
|
|
232
|
-
): "new-frontier" | "none" | "persisted" {
|
|
233
|
-
const interruption = error.interruption;
|
|
234
|
-
if (interruption === null) return "none";
|
|
235
|
-
if (request.command === "configure") {
|
|
236
|
-
throw invalidCaptureResponse(
|
|
237
|
-
request,
|
|
238
|
-
[error],
|
|
239
|
-
"Configure errors cannot carry segment interruption evidence.",
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
if (sameCaptureInterruption(interruption, frontier.lastInterruption)) {
|
|
243
|
-
return "persisted";
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const completion = segmentCompletionIn(response);
|
|
247
|
-
if (
|
|
248
|
-
completion !== undefined
|
|
249
|
-
&& interruption.segmentIndex === completion.segment.index
|
|
250
|
-
) {
|
|
251
|
-
if (
|
|
252
|
-
completion.interruption === null
|
|
253
|
-
|| !sameCaptureInterruption(interruption, completion.interruption)
|
|
254
|
-
) {
|
|
255
|
-
throw invalidCaptureResponse(
|
|
256
|
-
request,
|
|
257
|
-
[...response, error],
|
|
258
|
-
"An error cannot replace same-segment completion interruption evidence.",
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
return "persisted";
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const completedCount = frontier.completedSegmentCount
|
|
265
|
-
+ (completion === undefined ? 0 : 1);
|
|
266
|
-
const sourceFrontierUs = completion?.segment.clock.end.sourceTimeUs
|
|
267
|
-
?? frontier.logicalTimeUs;
|
|
268
|
-
const nativeFrontierUs = completion?.segment.clock.end.nativeTimeUs ?? 0;
|
|
269
|
-
if (
|
|
270
|
-
interruption.segmentIndex !== completedCount
|
|
271
|
-
|| interruption.sourceTimeUs !== sourceFrontierUs
|
|
272
|
-
|| interruption.nativeTimeUs < nativeFrontierUs
|
|
273
|
-
) {
|
|
274
|
-
throw invalidCaptureResponse(
|
|
275
|
-
request,
|
|
276
|
-
[...response, error],
|
|
277
|
-
"An unmatched error interruption must identify the next segment and the persisted source-time frontier.",
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
return "new-frontier";
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function validateCaptureResponse(
|
|
284
|
-
request: CaptureRequest,
|
|
285
|
-
events: readonly CaptureEvent[],
|
|
286
|
-
frontier: CaptureResponseFrontier,
|
|
287
|
-
): void {
|
|
288
|
-
const segment = segmentCompletionIn(events);
|
|
289
|
-
const session = sessionCompletionIn(events);
|
|
290
|
-
if (frontier.sessionCompletionPersisted && session !== undefined) {
|
|
291
|
-
throw invalidCaptureResponse(
|
|
292
|
-
request,
|
|
293
|
-
events,
|
|
294
|
-
"A completed session may not emit session-completed again.",
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const responseSegmentCount = segment === undefined ? 0 : 1;
|
|
299
|
-
const expectedCompletedSegmentCount = frontier.completedSegmentCount + responseSegmentCount;
|
|
300
|
-
const expectedLogicalTimeUs = segment?.segment.clock.end.sourceTimeUs ?? frontier.logicalTimeUs;
|
|
301
|
-
if (
|
|
302
|
-
segment !== undefined
|
|
303
|
-
&& segment.segment.index !== frontier.completedSegmentCount
|
|
304
|
-
) {
|
|
305
|
-
throw invalidCaptureResponse(
|
|
306
|
-
request,
|
|
307
|
-
events,
|
|
308
|
-
"Segment completion indices must advance from the persisted bundle frontier.",
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
if (
|
|
312
|
-
segment !== undefined
|
|
313
|
-
&& segment.segment.clock.start.sourceTimeUs !== frontier.logicalTimeUs
|
|
314
|
-
) {
|
|
315
|
-
throw invalidCaptureResponse(
|
|
316
|
-
request,
|
|
317
|
-
events,
|
|
318
|
-
"Segment completion must begin at the persisted bundle frontier.",
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
|
-
if (
|
|
322
|
-
session !== undefined
|
|
323
|
-
&& (
|
|
324
|
-
session.segmentCount !== expectedCompletedSegmentCount
|
|
325
|
-
|| session.durationUs !== expectedLogicalTimeUs
|
|
326
|
-
)
|
|
327
|
-
) {
|
|
328
|
-
throw invalidCaptureResponse(
|
|
329
|
-
request,
|
|
330
|
-
events,
|
|
331
|
-
"Session completion must equal the persisted bundle plus its optional segment completion.",
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
if (request.command === "resume" || request.command === "start") {
|
|
336
|
-
const started = events.find((
|
|
337
|
-
event,
|
|
338
|
-
): event is Extract<CaptureEvent, { readonly event: "segment-started" }> => (
|
|
339
|
-
event.event === "segment-started"
|
|
340
|
-
));
|
|
341
|
-
if (
|
|
342
|
-
started !== undefined
|
|
343
|
-
&& (
|
|
344
|
-
started.index !== expectedCompletedSegmentCount
|
|
345
|
-
|| started.startUs !== expectedLogicalTimeUs
|
|
346
|
-
)
|
|
347
|
-
) {
|
|
348
|
-
throw invalidCaptureResponse(
|
|
349
|
-
request,
|
|
350
|
-
events,
|
|
351
|
-
"A started segment must use the next index and begin at the completed bundle frontier.",
|
|
352
|
-
);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
if (request.command !== "configure") {
|
|
357
|
-
for (const event of events) {
|
|
358
|
-
const selectedSources = selectedSourcesIn(event);
|
|
359
|
-
if (
|
|
360
|
-
selectedSources !== undefined
|
|
361
|
-
&& !(
|
|
362
|
-
event.event === "segment-started"
|
|
363
|
-
&& (request.command === "start" || request.command === "resume")
|
|
364
|
-
)
|
|
365
|
-
&& !sameCaptureSourceInventory(selectedSources, frontier.sources)
|
|
366
|
-
) {
|
|
367
|
-
throw invalidCaptureResponse(
|
|
368
|
-
request,
|
|
369
|
-
events,
|
|
370
|
-
"Completion and status events must retain the current selected capture sources.",
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
if (request.command !== "snapshot" && request.command !== "status") return;
|
|
377
|
-
const status = events.at(-1);
|
|
378
|
-
if (status?.event !== "status") {
|
|
379
|
-
throw invalidCaptureResponse(request, events, "The response did not end with status.");
|
|
380
|
-
}
|
|
381
|
-
if (
|
|
382
|
-
status.state !== "recording"
|
|
383
|
-
&& status.state !== "paused"
|
|
384
|
-
&& status.state !== "stopped"
|
|
385
|
-
) {
|
|
386
|
-
throw invalidCaptureResponse(
|
|
387
|
-
request,
|
|
388
|
-
events,
|
|
389
|
-
"An active recording may report only recording, paused, or proven stopped status.",
|
|
390
|
-
);
|
|
391
|
-
}
|
|
392
|
-
if (frontier.sessionCompletionPersisted && status.state !== "stopped") {
|
|
393
|
-
throw invalidCaptureResponse(
|
|
394
|
-
request,
|
|
395
|
-
events,
|
|
396
|
-
"A completed session may report only stopped status.",
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
if (
|
|
400
|
-
(status.state === "stopped" && session === undefined && !frontier.sessionCompletionPersisted)
|
|
401
|
-
|| (status.state !== "stopped" && session !== undefined)
|
|
402
|
-
) {
|
|
403
|
-
throw invalidCaptureResponse(
|
|
404
|
-
request,
|
|
405
|
-
events,
|
|
406
|
-
"A stopped status requires session-completed proof, and no other status may follow same-request completion.",
|
|
407
|
-
);
|
|
408
|
-
}
|
|
409
|
-
if (segment !== undefined && session === undefined && status.state !== "paused") {
|
|
410
|
-
throw invalidCaptureResponse(
|
|
411
|
-
request,
|
|
412
|
-
events,
|
|
413
|
-
"A segment completion without session completion must reconcile to paused.",
|
|
414
|
-
);
|
|
415
|
-
}
|
|
416
|
-
if (
|
|
417
|
-
status.state === "paused"
|
|
418
|
-
&& (
|
|
419
|
-
status.completedSegmentCount !== expectedCompletedSegmentCount
|
|
420
|
-
|| status.logicalTimeUs !== expectedLogicalTimeUs
|
|
421
|
-
)
|
|
422
|
-
) {
|
|
423
|
-
throw invalidCaptureResponse(
|
|
424
|
-
request,
|
|
425
|
-
events,
|
|
426
|
-
"Paused status counters must equal the persisted bundle plus its optional segment completion.",
|
|
427
|
-
);
|
|
428
|
-
}
|
|
429
|
-
const expectedActiveSegmentIndex = status.state === "recording"
|
|
430
|
-
? expectedCompletedSegmentCount
|
|
431
|
-
: null;
|
|
432
|
-
if (status.activeSegmentIndex !== expectedActiveSegmentIndex) {
|
|
433
|
-
throw invalidCaptureResponse(
|
|
434
|
-
request,
|
|
435
|
-
events,
|
|
436
|
-
"Status activeSegmentIndex must match the capture frontier and lifecycle state.",
|
|
437
|
-
);
|
|
438
|
-
}
|
|
439
|
-
const expectedLastInterruption = segment?.interruption
|
|
440
|
-
?? frontier.lastInterruption;
|
|
441
|
-
if (
|
|
442
|
-
!sameCaptureInterruption(
|
|
443
|
-
status.lastInterruption,
|
|
444
|
-
expectedLastInterruption,
|
|
445
|
-
)
|
|
446
|
-
) {
|
|
447
|
-
throw invalidCaptureResponse(
|
|
448
|
-
request,
|
|
449
|
-
events,
|
|
450
|
-
"Status lastInterruption must match the latest accepted interruption evidence.",
|
|
451
|
-
);
|
|
452
|
-
}
|
|
453
|
-
if (
|
|
454
|
-
status.state === "recording"
|
|
455
|
-
&& (
|
|
456
|
-
status.completedSegmentCount !== frontier.completedSegmentCount
|
|
457
|
-
|| status.logicalTimeUs < frontier.logicalTimeUs
|
|
458
|
-
|| status.logicalTimeUs < frontier.priorSnapshotLogicalTimeUs
|
|
459
|
-
)
|
|
460
|
-
) {
|
|
461
|
-
throw invalidCaptureResponse(
|
|
462
|
-
request,
|
|
463
|
-
events,
|
|
464
|
-
"Recording status must preserve the persisted segment count and advance monotonically from both bundle and live frontiers.",
|
|
465
|
-
);
|
|
466
|
-
}
|
|
467
|
-
if (
|
|
468
|
-
status.state === "stopped"
|
|
469
|
-
&& (
|
|
470
|
-
status.completedSegmentCount !== expectedCompletedSegmentCount
|
|
471
|
-
|| status.logicalTimeUs !== expectedLogicalTimeUs
|
|
472
|
-
)
|
|
473
|
-
) {
|
|
474
|
-
throw invalidCaptureResponse(
|
|
475
|
-
request,
|
|
476
|
-
events,
|
|
477
|
-
"Stopped status counters must equal the completed bundle frontier.",
|
|
478
|
-
);
|
|
479
|
-
}
|
|
480
|
-
if (
|
|
481
|
-
session !== undefined
|
|
482
|
-
&& (
|
|
483
|
-
status.completedSegmentCount !== session.segmentCount
|
|
484
|
-
|| status.logicalTimeUs !== session.durationUs
|
|
485
|
-
)
|
|
486
|
-
) {
|
|
487
|
-
throw invalidCaptureResponse(
|
|
488
|
-
request,
|
|
489
|
-
events,
|
|
490
|
-
"Status counters must match the preceding session completion.",
|
|
491
|
-
);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
function spawnCaptureProcess(executable: string) {
|
|
496
|
-
return Bun.spawn([executable], {
|
|
497
|
-
env: environmentWithoutGatewayCredentials(process.env),
|
|
498
|
-
stdin: "pipe" as const,
|
|
499
|
-
stdout: "pipe" as const,
|
|
500
|
-
stderr: "pipe" as const,
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
const CAPTURE_HELPER_GRACEFUL_EXIT_TIMEOUT_MS = 30_000;
|
|
505
|
-
|
|
506
|
-
class BunCaptureTransport implements CaptureTransport {
|
|
507
|
-
readonly #child: ReturnType<typeof spawnCaptureProcess>;
|
|
508
|
-
readonly #reader: ReadableStreamDefaultReader<Uint8Array>;
|
|
509
|
-
readonly #decoder = new TextDecoder();
|
|
510
|
-
#buffer = "";
|
|
511
|
-
#closed = false;
|
|
512
|
-
#stderr = "";
|
|
513
|
-
|
|
514
|
-
constructor(executable: string) {
|
|
515
|
-
this.#child = spawnCaptureProcess(executable);
|
|
516
|
-
this.#reader = this.#child.stdout.getReader();
|
|
517
|
-
void this.#collectStderr();
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
async #collectStderr(): Promise<void> {
|
|
521
|
-
try {
|
|
522
|
-
for await (const chunk of this.#child.stderr) {
|
|
523
|
-
this.#stderr = `${this.#stderr}${new TextDecoder().decode(chunk)}`.slice(-16_384);
|
|
524
|
-
}
|
|
525
|
-
} catch {
|
|
526
|
-
// The structured protocol remains authoritative; stderr is best-effort evidence.
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
async readLine(timeoutMs: number): Promise<string> {
|
|
531
|
-
const deadline = Date.now() + timeoutMs;
|
|
532
|
-
while (true) {
|
|
533
|
-
const newline = this.#buffer.indexOf("\n");
|
|
534
|
-
if (newline !== -1) {
|
|
535
|
-
const line = this.#buffer.slice(0, newline);
|
|
536
|
-
this.#buffer = this.#buffer.slice(newline + 1);
|
|
537
|
-
return line;
|
|
538
|
-
}
|
|
539
|
-
const remaining = deadline - Date.now();
|
|
540
|
-
if (remaining <= 0) throw new CliError("subprocess", "Capture helper response timed out.");
|
|
541
|
-
const result = await Promise.race([
|
|
542
|
-
this.#reader.read(),
|
|
543
|
-
new Promise<never>((_, reject) => {
|
|
544
|
-
setTimeout(() => reject(new CliError("subprocess", "Capture helper response timed out.")), remaining);
|
|
545
|
-
}),
|
|
546
|
-
]);
|
|
547
|
-
if (result.done) {
|
|
548
|
-
throw new CliError(
|
|
549
|
-
"subprocess",
|
|
550
|
-
`Capture helper closed its protocol stream${this.#stderr === "" ? "." : `: ${this.#stderr.trim()}`}`,
|
|
551
|
-
);
|
|
552
|
-
}
|
|
553
|
-
this.#buffer += this.#decoder.decode(result.value, { stream: true });
|
|
554
|
-
if (new TextEncoder().encode(this.#buffer).byteLength > 64 * 1024) {
|
|
555
|
-
throw new CliError("invalid-data", "Capture helper emitted an oversized protocol line.");
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
async write(value: string): Promise<void> {
|
|
561
|
-
if (this.#closed) throw new CliError("conflict", "Capture helper transport is closed.");
|
|
562
|
-
void this.#child.stdin.write(value);
|
|
563
|
-
await this.#child.stdin.flush();
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
stderrTail(): string {
|
|
567
|
-
return this.#stderr;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
async close(): Promise<void> {
|
|
571
|
-
if (this.#closed) return;
|
|
572
|
-
this.#closed = true;
|
|
573
|
-
void this.#child.stdin.end();
|
|
574
|
-
try {
|
|
575
|
-
await this.#reader.cancel();
|
|
576
|
-
} catch {
|
|
577
|
-
// Closing stdout lets a finalizing helper exit even when its terminal
|
|
578
|
-
// events would otherwise fill the unread protocol pipe.
|
|
579
|
-
}
|
|
580
|
-
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
581
|
-
const exited = await Promise.race([
|
|
582
|
-
this.#child.exited.then(() => true),
|
|
583
|
-
new Promise<false>((resolve) => {
|
|
584
|
-
timeout = setTimeout(() => resolve(false), CAPTURE_HELPER_GRACEFUL_EXIT_TIMEOUT_MS);
|
|
585
|
-
}),
|
|
586
|
-
]);
|
|
587
|
-
if (timeout !== undefined) clearTimeout(timeout);
|
|
588
|
-
if (!exited) this.#child.kill();
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
export class BunCaptureTransportFactory implements CaptureTransportFactory {
|
|
593
|
-
spawn(executable: string): Promise<CaptureTransport> {
|
|
594
|
-
return Promise.resolve(new BunCaptureTransport(executable));
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function emptyCaptureSourceInventory(): CaptureSourceInventory {
|
|
599
|
-
return { audio: [], cameras: [], displays: [] };
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
function idleSnapshot(now: Date): RecordingSnapshot {
|
|
603
|
-
return {
|
|
604
|
-
availableSources: emptyCaptureSourceInventory(),
|
|
605
|
-
completedSegmentCount: 0,
|
|
606
|
-
effectiveConfig: null,
|
|
607
|
-
lastInterruption: null,
|
|
608
|
-
logicalTimeUs: 0,
|
|
609
|
-
permissions: null,
|
|
610
|
-
recordingId: null,
|
|
611
|
-
recordingRoot: null,
|
|
612
|
-
sources: emptyCaptureSourceInventory(),
|
|
613
|
-
state: "idle",
|
|
614
|
-
updatedAt: now.toISOString(),
|
|
615
|
-
};
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function activeStatePath(artifactRoot: string): string {
|
|
619
|
-
return join(artifactRoot, ".active-recording.json");
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
function parseEffectiveConfig(value: unknown): EffectiveRecordingConfig | null {
|
|
623
|
-
if (value === null) return null;
|
|
624
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
625
|
-
throw new CliError("invalid-data", "Recording effective config is invalid.");
|
|
626
|
-
}
|
|
627
|
-
const config = value as Readonly<Record<string, unknown>>;
|
|
628
|
-
const booleanField = (field: string): boolean => {
|
|
629
|
-
const item = config[field];
|
|
630
|
-
if (typeof item !== "boolean") {
|
|
631
|
-
throw new CliError("invalid-data", `Recording effective config ${field} is invalid.`);
|
|
632
|
-
}
|
|
633
|
-
return item;
|
|
634
|
-
};
|
|
635
|
-
const metadata = booleanField("metadata");
|
|
636
|
-
if (!metadata) throw new CliError("invalid-data", "Recording metadata cannot be disabled.");
|
|
637
|
-
const rawProcessIdentifier = config.interactionEventProcessIdentifier;
|
|
638
|
-
const interactionEventProcessIdentifier = rawProcessIdentifier === undefined
|
|
639
|
-
? undefined
|
|
640
|
-
: rawProcessIdentifier === null
|
|
641
|
-
? null
|
|
642
|
-
: (
|
|
643
|
-
typeof rawProcessIdentifier === "number"
|
|
644
|
-
&& Number.isInteger(rawProcessIdentifier)
|
|
645
|
-
&& rawProcessIdentifier > 0
|
|
646
|
-
&& rawProcessIdentifier <= 2_147_483_647
|
|
647
|
-
)
|
|
648
|
-
? rawProcessIdentifier
|
|
649
|
-
: (() => {
|
|
650
|
-
throw new CliError(
|
|
651
|
-
"invalid-data",
|
|
652
|
-
"Recording effective config interaction event process ID is invalid.",
|
|
653
|
-
);
|
|
654
|
-
})();
|
|
655
|
-
const rawFocusIdentities = config.typedTextFocusIdentities;
|
|
656
|
-
const parsedFocusIdentities = rawFocusIdentities === undefined
|
|
657
|
-
? undefined
|
|
658
|
-
: TypedTextFocusIdentitiesSchema.nullable().safeParse(rawFocusIdentities);
|
|
659
|
-
if (
|
|
660
|
-
parsedFocusIdentities !== undefined
|
|
661
|
-
&& !parsedFocusIdentities.success
|
|
662
|
-
) {
|
|
663
|
-
throw new CliError(
|
|
664
|
-
"invalid-data",
|
|
665
|
-
"Recording effective config typed-text focus identities are invalid.",
|
|
666
|
-
);
|
|
667
|
-
}
|
|
668
|
-
const typedTextFocusIdentities = parsedFocusIdentities?.data;
|
|
669
|
-
const parsedCamera = CaptureDeviceSelectionSchema.safeParse(config.camera);
|
|
670
|
-
if (!parsedCamera.success) {
|
|
671
|
-
throw new CliError("invalid-data", "Recording effective config camera selection is invalid.");
|
|
672
|
-
}
|
|
673
|
-
const parsedDisplays = CaptureDisplaySelectionSchema.safeParse(config.displays);
|
|
674
|
-
if (!parsedDisplays.success) {
|
|
675
|
-
throw new CliError("invalid-data", "Recording effective config display selection is invalid.");
|
|
676
|
-
}
|
|
677
|
-
const parsedMicrophone = CaptureDeviceSelectionSchema.safeParse(config.microphone);
|
|
678
|
-
if (!parsedMicrophone.success) {
|
|
679
|
-
throw new CliError("invalid-data", "Recording effective config microphone selection is invalid.");
|
|
680
|
-
}
|
|
681
|
-
return {
|
|
682
|
-
camera: parsedCamera.data,
|
|
683
|
-
displays: parsedDisplays.data,
|
|
684
|
-
...(interactionEventProcessIdentifier === undefined
|
|
685
|
-
? {}
|
|
686
|
-
: { interactionEventProcessIdentifier }),
|
|
687
|
-
metadata: true,
|
|
688
|
-
microphone: parsedMicrophone.data,
|
|
689
|
-
strictInputs: booleanField("strictInputs"),
|
|
690
|
-
systemAudio: booleanField("systemAudio"),
|
|
691
|
-
typedText: booleanField("typedText"),
|
|
692
|
-
...(typedTextFocusIdentities === undefined
|
|
693
|
-
? {}
|
|
694
|
-
: {
|
|
695
|
-
typedTextFocusIdentities: typedTextFocusIdentities === null
|
|
696
|
-
? null
|
|
697
|
-
: typedTextFocusIdentities.map(identity => ({ ...identity })),
|
|
698
|
-
}),
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
export function parseRecordingSnapshot(value: unknown): RecordingSnapshot {
|
|
703
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
704
|
-
throw new CliError("invalid-data", "Active recording state must be a JSON object.");
|
|
705
|
-
}
|
|
706
|
-
const state = value as Readonly<Record<string, unknown>>;
|
|
707
|
-
if (state.state !== "idle" && state.state !== "recording" && state.state !== "paused") {
|
|
708
|
-
throw new CliError("invalid-data", "Active recording state has an unknown phase.");
|
|
709
|
-
}
|
|
710
|
-
if (
|
|
711
|
-
typeof state.completedSegmentCount !== "number"
|
|
712
|
-
|| !Number.isSafeInteger(state.completedSegmentCount)
|
|
713
|
-
|| state.completedSegmentCount < 0
|
|
714
|
-
|| typeof state.logicalTimeUs !== "number"
|
|
715
|
-
|| !Number.isSafeInteger(state.logicalTimeUs)
|
|
716
|
-
|| state.logicalTimeUs < 0
|
|
717
|
-
|| typeof state.updatedAt !== "string"
|
|
718
|
-
) {
|
|
719
|
-
throw new CliError("invalid-data", "Active recording state has invalid counters or timestamp.");
|
|
720
|
-
}
|
|
721
|
-
const recordingId = state.recordingId;
|
|
722
|
-
const recordingRoot = state.recordingRoot;
|
|
723
|
-
const permissions = CapturePermissionsSchema.nullable().safeParse(
|
|
724
|
-
state.permissions,
|
|
725
|
-
);
|
|
726
|
-
const sources = CaptureSourceInventorySchema.safeParse(state.sources);
|
|
727
|
-
const availableSources = CaptureSourceInventorySchema.safeParse(
|
|
728
|
-
state.availableSources,
|
|
729
|
-
);
|
|
730
|
-
const lastInterruption = CaptureInterruptionSchema.nullable().safeParse(
|
|
731
|
-
state.lastInterruption,
|
|
732
|
-
);
|
|
733
|
-
if (
|
|
734
|
-
(recordingId !== null && typeof recordingId !== "string")
|
|
735
|
-
|| (recordingRoot !== null && typeof recordingRoot !== "string")
|
|
736
|
-
|| !permissions.success
|
|
737
|
-
|| !sources.success
|
|
738
|
-
|| !availableSources.success
|
|
739
|
-
|| !lastInterruption.success
|
|
740
|
-
) {
|
|
741
|
-
throw new CliError(
|
|
742
|
-
"invalid-data",
|
|
743
|
-
"Active recording state has invalid identity, permissions, sources, or interruption evidence.",
|
|
744
|
-
);
|
|
745
|
-
}
|
|
746
|
-
return {
|
|
747
|
-
availableSources: availableSources.data,
|
|
748
|
-
completedSegmentCount: state.completedSegmentCount,
|
|
749
|
-
effectiveConfig: parseEffectiveConfig(state.effectiveConfig),
|
|
750
|
-
lastInterruption: lastInterruption.data,
|
|
751
|
-
logicalTimeUs: state.logicalTimeUs,
|
|
752
|
-
permissions: permissions.data,
|
|
753
|
-
recordingId,
|
|
754
|
-
recordingRoot,
|
|
755
|
-
sources: sources.data,
|
|
756
|
-
state: state.state,
|
|
757
|
-
updatedAt: state.updatedAt,
|
|
758
|
-
};
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
export async function readRepositoryRecordingState(
|
|
762
|
-
artifactRoot: string,
|
|
763
|
-
now: () => Date = () => new Date(),
|
|
764
|
-
): Promise<RecordingSnapshot> {
|
|
765
|
-
try {
|
|
766
|
-
const bytes = await readFile(activeStatePath(artifactRoot), "utf8");
|
|
767
|
-
let value: unknown;
|
|
768
|
-
try {
|
|
769
|
-
value = JSON.parse(bytes);
|
|
770
|
-
} catch {
|
|
771
|
-
throw new CliError("invalid-data", "Active recording state is not valid JSON.");
|
|
772
|
-
}
|
|
773
|
-
return parseRecordingSnapshot(value);
|
|
774
|
-
} catch (error) {
|
|
775
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT") return idleSnapshot(now());
|
|
776
|
-
throw error;
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
async function writeRepositoryRecordingState(
|
|
781
|
-
artifactRoot: string,
|
|
782
|
-
snapshot: RecordingSnapshot,
|
|
783
|
-
): Promise<void> {
|
|
784
|
-
await ensurePrivateDirectory(artifactRoot);
|
|
785
|
-
const destination = activeStatePath(artifactRoot);
|
|
786
|
-
try {
|
|
787
|
-
if ((await lstat(destination)).isSymbolicLink()) {
|
|
788
|
-
throw new CliError("unsafe-path", `Active recording state is a symlink: ${destination}`);
|
|
789
|
-
}
|
|
790
|
-
} catch (error) {
|
|
791
|
-
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
792
|
-
}
|
|
793
|
-
const temporary = join(artifactRoot, `.active-recording.${randomUUID()}.tmp`);
|
|
794
|
-
try {
|
|
795
|
-
await writeFile(temporary, `${JSON.stringify(snapshot)}\n`, { flag: "wx", mode: 0o600 });
|
|
796
|
-
await rename(temporary, destination);
|
|
797
|
-
} finally {
|
|
798
|
-
await rm(temporary, { force: true });
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
function permissionsFrom(event: Extract<
|
|
803
|
-
CaptureEvent,
|
|
804
|
-
{ readonly event: "configured" | "segment-started" | "status" }
|
|
805
|
-
>): CapturePermissions {
|
|
806
|
-
return { ...event.permissions };
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
function notifyCaptureObserver(
|
|
810
|
-
observer: ((event: CaptureEvent) => void) | undefined,
|
|
811
|
-
event: CaptureEvent,
|
|
812
|
-
): void {
|
|
813
|
-
if (observer === undefined) return;
|
|
814
|
-
try {
|
|
815
|
-
// Observers receive evidence, not authority over the controller's parsed
|
|
816
|
-
// protocol object. Their diagnostics must not fail or mutate capture.
|
|
817
|
-
observer(structuredClone(event));
|
|
818
|
-
} catch {
|
|
819
|
-
// Capture remains authoritative when optional observation fails.
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
export interface CaptureHelperRecordingControllerOptions {
|
|
824
|
-
readonly artifactRoot: string;
|
|
825
|
-
readonly executable: string;
|
|
826
|
-
readonly io: Pick<CliIo, "now">;
|
|
827
|
-
readonly onCaptureEvent?: (event: CaptureEvent) => void;
|
|
828
|
-
readonly requestTimeoutMs?: number;
|
|
829
|
-
readonly ffprobeExecutable?: string;
|
|
830
|
-
readonly runner?: ProcessRunner;
|
|
831
|
-
readonly transportFactory?: CaptureTransportFactory;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
interface FailedSegmentCompletion {
|
|
835
|
-
readonly failure: unknown;
|
|
836
|
-
readonly interruption: CaptureInterruption | null;
|
|
837
|
-
readonly segment: SegmentCompletion;
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
export class CaptureHelperRecordingController implements RecordingController {
|
|
841
|
-
readonly #artifactRoot: string;
|
|
842
|
-
readonly #executable: string;
|
|
843
|
-
readonly #now: () => Date;
|
|
844
|
-
readonly #onCaptureEvent: ((event: CaptureEvent) => void) | undefined;
|
|
845
|
-
readonly #requestTimeoutMs: number;
|
|
846
|
-
readonly #transportFactory: CaptureTransportFactory;
|
|
847
|
-
#transport: CaptureTransport | undefined;
|
|
848
|
-
#bundleWriter: CaptureBundleWriter | undefined;
|
|
849
|
-
#snapshot: RecordingSnapshot;
|
|
850
|
-
#effectiveConfig: EffectiveRecordingConfig | null = null;
|
|
851
|
-
#closed = false;
|
|
852
|
-
#requestCounter = 0;
|
|
853
|
-
#helperVersion = "unknown";
|
|
854
|
-
#sessionCompletionPersisted = false;
|
|
855
|
-
#failedSegmentCompletion: FailedSegmentCompletion | undefined;
|
|
856
|
-
#pendingFailureInterruption: CaptureInterruption | undefined;
|
|
857
|
-
readonly #verifier: CaptureMediaVerifier;
|
|
858
|
-
|
|
859
|
-
constructor(options: CaptureHelperRecordingControllerOptions) {
|
|
860
|
-
this.#artifactRoot = options.artifactRoot;
|
|
861
|
-
this.#executable = options.executable;
|
|
862
|
-
this.#now = options.io.now;
|
|
863
|
-
this.#onCaptureEvent = options.onCaptureEvent;
|
|
864
|
-
this.#requestTimeoutMs = options.requestTimeoutMs ?? 240_000;
|
|
865
|
-
this.#transportFactory = options.transportFactory ?? new BunCaptureTransportFactory();
|
|
866
|
-
this.#verifier = new CaptureMediaVerifier({
|
|
867
|
-
...(options.ffprobeExecutable === undefined ? {} : { ffprobe: options.ffprobeExecutable }),
|
|
868
|
-
...(options.runner === undefined ? {} : { runner: options.runner }),
|
|
869
|
-
});
|
|
870
|
-
this.#snapshot = idleSnapshot(this.#now());
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
async #ensureTransport(): Promise<CaptureTransport> {
|
|
874
|
-
if (this.#closed) throw new CliError("conflict", "Recording controller is closed.");
|
|
875
|
-
if (this.#transport !== undefined) return this.#transport;
|
|
876
|
-
try {
|
|
877
|
-
if (!(await stat(this.#executable)).isFile()) {
|
|
878
|
-
throw new CliError("unavailable", `Capture helper is not a file: ${this.#executable}`);
|
|
879
|
-
}
|
|
880
|
-
} catch (error) {
|
|
881
|
-
if (error instanceof CliError) throw error;
|
|
882
|
-
throw new CliError(
|
|
883
|
-
"unavailable",
|
|
884
|
-
`Capture helper is unavailable at ${this.#executable}. Run: bun run build:desktop:capture:macos`,
|
|
885
|
-
);
|
|
886
|
-
}
|
|
887
|
-
const transport = await this.#transportFactory.spawn(this.#executable);
|
|
888
|
-
this.#transport = transport;
|
|
889
|
-
const ready = parseCaptureEventLine(await transport.readLine(this.#requestTimeoutMs));
|
|
890
|
-
notifyCaptureObserver(this.#onCaptureEvent, ready);
|
|
891
|
-
if (ready.event !== "ready") {
|
|
892
|
-
await transport.close();
|
|
893
|
-
this.#transport = undefined;
|
|
894
|
-
throw new CliError("invalid-data", `Capture helper emitted ${ready.event} before ready.`);
|
|
895
|
-
}
|
|
896
|
-
this.#helperVersion = ready.helperVersion;
|
|
897
|
-
return transport;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
#requestId(command: string): string {
|
|
901
|
-
this.#requestCounter += 1;
|
|
902
|
-
return `cli:${command}:${this.#requestCounter}`;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
async #settleSessionFailure(error: unknown): Promise<never> {
|
|
906
|
-
const failedRecordingId = this.#snapshot.recordingId ?? this.#bundleWriter?.manifest.recordingId ?? null;
|
|
907
|
-
const failedRecordingRoot = this.#snapshot.recordingRoot;
|
|
908
|
-
const pendingFailureInterruption = this.#pendingFailureInterruption;
|
|
909
|
-
const failedLastInterruption =
|
|
910
|
-
pendingFailureInterruption ?? this.#snapshot.lastInterruption;
|
|
911
|
-
const failedSources = structuredClone(this.#snapshot.sources);
|
|
912
|
-
const includeFailedSources = Buffer.byteLength(
|
|
913
|
-
JSON.stringify(failedSources),
|
|
914
|
-
) <= MAX_FAILURE_RECOVERY_SOURCES_BYTES;
|
|
915
|
-
const failedSnapshot = {
|
|
916
|
-
lastInterruption: failedLastInterruption === null
|
|
917
|
-
? null
|
|
918
|
-
: structuredClone(failedLastInterruption),
|
|
919
|
-
logicalTimeUs: this.#snapshot.logicalTimeUs,
|
|
920
|
-
permissions: this.#snapshot.permissions === null
|
|
921
|
-
? null
|
|
922
|
-
: structuredClone(this.#snapshot.permissions),
|
|
923
|
-
...(includeFailedSources ? { sources: failedSources } : {}),
|
|
924
|
-
} as const;
|
|
925
|
-
const recoveryErrors: string[] = [];
|
|
926
|
-
const failedSegmentCompletion = this.#failedSegmentCompletion;
|
|
927
|
-
let failedSegmentCompletionPersisted = failedSegmentCompletion === undefined;
|
|
928
|
-
const persistFailedSegmentCompletion = async (
|
|
929
|
-
errorPrefix: "completion frontier: " | "completion frontier retry: ",
|
|
930
|
-
): Promise<void> => {
|
|
931
|
-
if (
|
|
932
|
-
failedSegmentCompletion === undefined
|
|
933
|
-
|| this.#bundleWriter === undefined
|
|
934
|
-
) return;
|
|
935
|
-
try {
|
|
936
|
-
await this.#bundleWriter.recordFailedSegmentCompletion(
|
|
937
|
-
failedSegmentCompletion.segment,
|
|
938
|
-
failedSegmentCompletion.interruption,
|
|
939
|
-
failedSegmentCompletion.failure,
|
|
940
|
-
);
|
|
941
|
-
failedSegmentCompletionPersisted = true;
|
|
942
|
-
for (let index = recoveryErrors.length - 1; index >= 0; index -= 1) {
|
|
943
|
-
if (recoveryErrors[index]?.startsWith("completion frontier")) {
|
|
944
|
-
recoveryErrors.splice(index, 1);
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
} catch (persistenceError) {
|
|
948
|
-
recoveryErrors.push(`${errorPrefix}${errorMessage(persistenceError)}`);
|
|
949
|
-
}
|
|
950
|
-
};
|
|
951
|
-
await persistFailedSegmentCompletion("completion frontier: ");
|
|
952
|
-
let pendingFailureInterruptionPersisted =
|
|
953
|
-
pendingFailureInterruption === undefined;
|
|
954
|
-
const persistPendingFailureInterruption = async (
|
|
955
|
-
errorPrefix: "failure interruption: " | "failure interruption retry: ",
|
|
956
|
-
): Promise<void> => {
|
|
957
|
-
if (
|
|
958
|
-
pendingFailureInterruption === undefined
|
|
959
|
-
|| this.#bundleWriter === undefined
|
|
960
|
-
) return;
|
|
961
|
-
try {
|
|
962
|
-
await this.#bundleWriter.recordFailureInterruption(
|
|
963
|
-
pendingFailureInterruption,
|
|
964
|
-
);
|
|
965
|
-
pendingFailureInterruptionPersisted = true;
|
|
966
|
-
for (let index = recoveryErrors.length - 1; index >= 0; index -= 1) {
|
|
967
|
-
if (recoveryErrors[index]?.startsWith("failure interruption")) {
|
|
968
|
-
recoveryErrors.splice(index, 1);
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
} catch (persistenceError) {
|
|
972
|
-
recoveryErrors.push(`${errorPrefix}${errorMessage(persistenceError)}`);
|
|
973
|
-
}
|
|
974
|
-
};
|
|
975
|
-
await persistPendingFailureInterruption("failure interruption: ");
|
|
976
|
-
let terminalManifestState: "failed" | "stopped" | "unsettled" = this.#sessionCompletionPersisted
|
|
977
|
-
? "stopped"
|
|
978
|
-
: "unsettled";
|
|
979
|
-
|
|
980
|
-
if (this.#bundleWriter !== undefined && !this.#sessionCompletionPersisted) {
|
|
981
|
-
const durationUs = this.#bundleWriter.manifest.timeline.durationUs;
|
|
982
|
-
try {
|
|
983
|
-
await this.#bundleWriter.setState("failed", durationUs);
|
|
984
|
-
terminalManifestState = "failed";
|
|
985
|
-
} catch (settlementError) {
|
|
986
|
-
recoveryErrors.push(`manifest: ${errorMessage(settlementError)}`);
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
const transport = this.#transport;
|
|
991
|
-
this.#transport = undefined;
|
|
992
|
-
if (transport !== undefined) {
|
|
993
|
-
try {
|
|
994
|
-
await transport.close();
|
|
995
|
-
} catch (transportError) {
|
|
996
|
-
recoveryErrors.push(`helper: ${errorMessage(transportError)}`);
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
if (!failedSegmentCompletionPersisted) {
|
|
1001
|
-
await persistFailedSegmentCompletion("completion frontier retry: ");
|
|
1002
|
-
}
|
|
1003
|
-
if (!pendingFailureInterruptionPersisted) {
|
|
1004
|
-
await persistPendingFailureInterruption("failure interruption retry: ");
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
if (terminalManifestState === "unsettled" && this.#bundleWriter !== undefined) {
|
|
1008
|
-
try {
|
|
1009
|
-
await this.#bundleWriter.setState(
|
|
1010
|
-
"failed",
|
|
1011
|
-
this.#bundleWriter.manifest.timeline.durationUs,
|
|
1012
|
-
);
|
|
1013
|
-
terminalManifestState = "failed";
|
|
1014
|
-
const manifestFailureIndex = recoveryErrors.findIndex((item) => item.startsWith("manifest: "));
|
|
1015
|
-
if (manifestFailureIndex !== -1) recoveryErrors.splice(manifestFailureIndex, 1);
|
|
1016
|
-
} catch (settlementError) {
|
|
1017
|
-
const message = `manifest retry: ${errorMessage(settlementError)}`;
|
|
1018
|
-
if (!recoveryErrors.includes(message)) recoveryErrors.push(message);
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
try {
|
|
1023
|
-
await rm(activeStatePath(this.#artifactRoot), { force: true });
|
|
1024
|
-
} catch (activeStateError) {
|
|
1025
|
-
recoveryErrors.push(`active state: ${errorMessage(activeStateError)}`);
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
this.#bundleWriter = undefined;
|
|
1029
|
-
this.#effectiveConfig = null;
|
|
1030
|
-
this.#failedSegmentCompletion = undefined;
|
|
1031
|
-
this.#pendingFailureInterruption = undefined;
|
|
1032
|
-
this.#sessionCompletionPersisted = false;
|
|
1033
|
-
this.#snapshot = idleSnapshot(this.#now());
|
|
1034
|
-
if (recoveryErrors.length > 0) this.#closed = true;
|
|
1035
|
-
|
|
1036
|
-
const failure = asCliError(error);
|
|
1037
|
-
const recovery = {
|
|
1038
|
-
controllerReusable: recoveryErrors.length === 0,
|
|
1039
|
-
recordingId: failedRecordingId,
|
|
1040
|
-
recordingRoot: failedRecordingRoot,
|
|
1041
|
-
snapshot: failedSnapshot,
|
|
1042
|
-
terminalManifestState,
|
|
1043
|
-
} as const;
|
|
1044
|
-
if (recoveryErrors.length > 0) {
|
|
1045
|
-
throw new CliError(
|
|
1046
|
-
"internal",
|
|
1047
|
-
`${failure.message} Capture recovery was incomplete: ${recoveryErrors.join("; ")}`,
|
|
1048
|
-
{
|
|
1049
|
-
captureFailure: { code: failure.code, details: failure.details ?? null },
|
|
1050
|
-
recovery,
|
|
1051
|
-
recoveryErrors,
|
|
1052
|
-
},
|
|
1053
|
-
);
|
|
1054
|
-
}
|
|
1055
|
-
throw new CliError(failure.code, failure.message, {
|
|
1056
|
-
...(failure.details ?? {}),
|
|
1057
|
-
recovery,
|
|
1058
|
-
});
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
async #discardUninitializedSession(recordingRoot: string, error: unknown): Promise<never> {
|
|
1062
|
-
const recoveryErrors: string[] = [];
|
|
1063
|
-
const transport = this.#transport;
|
|
1064
|
-
this.#transport = undefined;
|
|
1065
|
-
if (transport !== undefined) {
|
|
1066
|
-
try {
|
|
1067
|
-
await transport.close();
|
|
1068
|
-
} catch (transportError) {
|
|
1069
|
-
recoveryErrors.push(`helper: ${errorMessage(transportError)}`);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
try {
|
|
1073
|
-
await rm(recordingRoot, { force: true, recursive: true });
|
|
1074
|
-
await rm(activeStatePath(this.#artifactRoot), { force: true });
|
|
1075
|
-
} catch (cleanupError) {
|
|
1076
|
-
recoveryErrors.push(`recording cleanup: ${errorMessage(cleanupError)}`);
|
|
1077
|
-
}
|
|
1078
|
-
this.#bundleWriter = undefined;
|
|
1079
|
-
this.#effectiveConfig = null;
|
|
1080
|
-
this.#failedSegmentCompletion = undefined;
|
|
1081
|
-
this.#pendingFailureInterruption = undefined;
|
|
1082
|
-
this.#sessionCompletionPersisted = false;
|
|
1083
|
-
this.#snapshot = idleSnapshot(this.#now());
|
|
1084
|
-
if (recoveryErrors.length > 0) this.#closed = true;
|
|
1085
|
-
|
|
1086
|
-
const failure = asCliError(error);
|
|
1087
|
-
if (recoveryErrors.length > 0) {
|
|
1088
|
-
throw new CliError(
|
|
1089
|
-
"internal",
|
|
1090
|
-
`${failure.message} Capture recovery was incomplete: ${recoveryErrors.join("; ")}`,
|
|
1091
|
-
{ captureFailure: { code: failure.code, details: failure.details ?? null }, recoveryErrors },
|
|
1092
|
-
);
|
|
1093
|
-
}
|
|
1094
|
-
throw failure;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
async #runSessionMutation<Result>(operation: () => Promise<Result>): Promise<Result> {
|
|
1098
|
-
try {
|
|
1099
|
-
return await operation();
|
|
1100
|
-
} catch (error) {
|
|
1101
|
-
if (error instanceof NonFatalCaptureCommandError) throw error;
|
|
1102
|
-
return await this.#settleSessionFailure(error);
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
#responseFrontier(): CaptureResponseFrontier {
|
|
1107
|
-
const manifest = this.#bundleWriter?.manifest;
|
|
1108
|
-
return {
|
|
1109
|
-
completedSegmentCount:
|
|
1110
|
-
manifest?.timeline.nativeClock.segments.length
|
|
1111
|
-
?? this.#snapshot.completedSegmentCount,
|
|
1112
|
-
lastInterruption: this.#snapshot.lastInterruption,
|
|
1113
|
-
logicalTimeUs: manifest?.timeline.durationUs ?? this.#snapshot.logicalTimeUs,
|
|
1114
|
-
priorSnapshotLogicalTimeUs: this.#snapshot.logicalTimeUs,
|
|
1115
|
-
sessionCompletionPersisted: this.#sessionCompletionPersisted,
|
|
1116
|
-
sources: this.#snapshot.sources,
|
|
1117
|
-
};
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
async #applyResponseEvent(event: CaptureEvent): Promise<void> {
|
|
1121
|
-
if (event.event === "segment-completed") {
|
|
1122
|
-
try {
|
|
1123
|
-
await this.#bundleWriter?.appendSegment(
|
|
1124
|
-
event.segment,
|
|
1125
|
-
event.interruption,
|
|
1126
|
-
);
|
|
1127
|
-
} catch (failure) {
|
|
1128
|
-
this.#failedSegmentCompletion = {
|
|
1129
|
-
failure,
|
|
1130
|
-
interruption: event.interruption,
|
|
1131
|
-
segment: event.segment,
|
|
1132
|
-
};
|
|
1133
|
-
this.#snapshot = {
|
|
1134
|
-
...this.#snapshot,
|
|
1135
|
-
completedSegmentCount: this.#snapshot.completedSegmentCount + 1,
|
|
1136
|
-
lastInterruption:
|
|
1137
|
-
event.interruption ?? this.#snapshot.lastInterruption,
|
|
1138
|
-
logicalTimeUs: Math.max(
|
|
1139
|
-
this.#snapshot.logicalTimeUs,
|
|
1140
|
-
event.segment.clock.end.sourceTimeUs,
|
|
1141
|
-
),
|
|
1142
|
-
updatedAt: this.#now().toISOString(),
|
|
1143
|
-
};
|
|
1144
|
-
throw failure;
|
|
1145
|
-
}
|
|
1146
|
-
this.#snapshot = {
|
|
1147
|
-
...this.#snapshot,
|
|
1148
|
-
completedSegmentCount: this.#snapshot.completedSegmentCount + 1,
|
|
1149
|
-
lastInterruption:
|
|
1150
|
-
event.interruption ?? this.#snapshot.lastInterruption,
|
|
1151
|
-
logicalTimeUs: Math.max(
|
|
1152
|
-
this.#snapshot.logicalTimeUs,
|
|
1153
|
-
event.segment.clock.end.sourceTimeUs,
|
|
1154
|
-
),
|
|
1155
|
-
updatedAt: this.#now().toISOString(),
|
|
1156
|
-
};
|
|
1157
|
-
}
|
|
1158
|
-
if (event.event === "session-completed") {
|
|
1159
|
-
if (this.#bundleWriter === undefined) {
|
|
1160
|
-
throw new CliError("internal", "Capture helper completed a session without an initialized recording bundle.");
|
|
1161
|
-
}
|
|
1162
|
-
await this.#bundleWriter.setState("stopped", event.durationUs);
|
|
1163
|
-
this.#sessionCompletionPersisted = true;
|
|
1164
|
-
this.#snapshot = {
|
|
1165
|
-
...this.#snapshot,
|
|
1166
|
-
completedSegmentCount: event.segmentCount,
|
|
1167
|
-
logicalTimeUs: event.durationUs,
|
|
1168
|
-
state: "idle",
|
|
1169
|
-
updatedAt: this.#now().toISOString(),
|
|
1170
|
-
};
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
async #send(request: CaptureRequest): Promise<CaptureEvent> {
|
|
1175
|
-
const transport = await this.#ensureTransport();
|
|
1176
|
-
await transport.write(encodeCaptureRequest(request));
|
|
1177
|
-
const response: CaptureEvent[] = [];
|
|
1178
|
-
const sequences = CAPTURE_RESPONSE_SEQUENCES[request.command];
|
|
1179
|
-
const frontier = this.#responseFrontier();
|
|
1180
|
-
while (true) {
|
|
1181
|
-
const event = parseCaptureEventLine(await transport.readLine(this.#requestTimeoutMs));
|
|
1182
|
-
if (!("requestId" in event) || event.requestId !== request.requestId) {
|
|
1183
|
-
throw new CliError(
|
|
1184
|
-
"invalid-data",
|
|
1185
|
-
`Capture helper response ID ${
|
|
1186
|
-
"requestId" in event ? event.requestId : "<missing>"
|
|
1187
|
-
} did not match ${request.requestId}.`,
|
|
1188
|
-
);
|
|
1189
|
-
}
|
|
1190
|
-
notifyCaptureObserver(this.#onCaptureEvent, event);
|
|
1191
|
-
if (event.event === "error") {
|
|
1192
|
-
const mayFollowDeferredResumeCompletion = (
|
|
1193
|
-
request.command === "resume"
|
|
1194
|
-
&& response.length === 1
|
|
1195
|
-
&& response[0]?.event === "segment-completed"
|
|
1196
|
-
);
|
|
1197
|
-
if (response.length > 0 && !mayFollowDeferredResumeCompletion) {
|
|
1198
|
-
throw invalidCaptureResponse(
|
|
1199
|
-
request,
|
|
1200
|
-
[...response, event],
|
|
1201
|
-
"Errors may not follow completion evidence.",
|
|
1202
|
-
);
|
|
1203
|
-
}
|
|
1204
|
-
const completedResponse = [...response, event];
|
|
1205
|
-
const interruptionDisposition = validateCaptureError(
|
|
1206
|
-
request,
|
|
1207
|
-
response,
|
|
1208
|
-
event,
|
|
1209
|
-
frontier,
|
|
1210
|
-
);
|
|
1211
|
-
if (response.length > 0) {
|
|
1212
|
-
validateCaptureResponse(request, completedResponse, frontier);
|
|
1213
|
-
}
|
|
1214
|
-
const nominallyNonFatal = event.recoverable
|
|
1215
|
-
&& (request.command === "resume" || event.code === "invalid-state");
|
|
1216
|
-
const nonFatal = nominallyNonFatal
|
|
1217
|
-
&& interruptionDisposition !== "new-frontier";
|
|
1218
|
-
if (!nonFatal && event.interruption !== null) {
|
|
1219
|
-
this.#pendingFailureInterruption = event.interruption;
|
|
1220
|
-
}
|
|
1221
|
-
for (const acceptedEvent of response) {
|
|
1222
|
-
await this.#applyResponseEvent(acceptedEvent);
|
|
1223
|
-
}
|
|
1224
|
-
if (event.interruption !== null) {
|
|
1225
|
-
this.#snapshot = {
|
|
1226
|
-
...this.#snapshot,
|
|
1227
|
-
lastInterruption: event.interruption,
|
|
1228
|
-
updatedAt: this.#now().toISOString(),
|
|
1229
|
-
};
|
|
1230
|
-
}
|
|
1231
|
-
if (
|
|
1232
|
-
event.interruption !== null
|
|
1233
|
-
&& this.#bundleWriter !== undefined
|
|
1234
|
-
) {
|
|
1235
|
-
const alreadyPersisted = this.#bundleWriter.manifest.interruptions
|
|
1236
|
-
.some(candidate =>
|
|
1237
|
-
sameCaptureInterruption(candidate, event.interruption)
|
|
1238
|
-
);
|
|
1239
|
-
if (nonFatal && !alreadyPersisted) {
|
|
1240
|
-
throw invalidCaptureResponse(
|
|
1241
|
-
request,
|
|
1242
|
-
completedResponse,
|
|
1243
|
-
"A recoverable error cannot introduce interruption evidence that was not persisted by completion.",
|
|
1244
|
-
);
|
|
1245
|
-
}
|
|
1246
|
-
if (!nonFatal) {
|
|
1247
|
-
await this.#bundleWriter.recordFailureInterruption(
|
|
1248
|
-
event.interruption,
|
|
1249
|
-
);
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
if (request.command === "resume" && nonFatal && event.state === "paused") {
|
|
1253
|
-
if (this.#bundleWriter === undefined) {
|
|
1254
|
-
throw new CliError("internal", "Capture helper rejected resume without a recording bundle.");
|
|
1255
|
-
}
|
|
1256
|
-
this.#snapshot = {
|
|
1257
|
-
...this.#snapshot,
|
|
1258
|
-
state: "paused",
|
|
1259
|
-
updatedAt: this.#now().toISOString(),
|
|
1260
|
-
};
|
|
1261
|
-
await this.#bundleWriter.setState("paused", this.#snapshot.logicalTimeUs);
|
|
1262
|
-
await writeRepositoryRecordingState(this.#artifactRoot, this.#snapshot);
|
|
1263
|
-
} else if (
|
|
1264
|
-
nonFatal
|
|
1265
|
-
&& this.#snapshot.recordingId !== null
|
|
1266
|
-
) {
|
|
1267
|
-
await writeRepositoryRecordingState(
|
|
1268
|
-
this.#artifactRoot,
|
|
1269
|
-
this.#snapshot,
|
|
1270
|
-
);
|
|
1271
|
-
}
|
|
1272
|
-
if (nonFatal) {
|
|
1273
|
-
throw new NonFatalCaptureCommandError(
|
|
1274
|
-
event.code === "invalid-state" ? "conflict" : "unavailable",
|
|
1275
|
-
event,
|
|
1276
|
-
);
|
|
1277
|
-
}
|
|
1278
|
-
throw new CliError(
|
|
1279
|
-
event.recoverable ? "unavailable" : "subprocess",
|
|
1280
|
-
`Capture helper ${event.code}: ${event.message}`,
|
|
1281
|
-
{
|
|
1282
|
-
helperCode: event.code,
|
|
1283
|
-
interruption: event.interruption,
|
|
1284
|
-
recoverable: event.recoverable,
|
|
1285
|
-
state: event.state,
|
|
1286
|
-
},
|
|
1287
|
-
);
|
|
1288
|
-
}
|
|
1289
|
-
response.push(event);
|
|
1290
|
-
const candidates = sequences.filter(sequence => isCaptureResponsePrefix(sequence, response));
|
|
1291
|
-
if (candidates.length === 0) {
|
|
1292
|
-
throw invalidCaptureResponse(request, response);
|
|
1293
|
-
}
|
|
1294
|
-
const complete = candidates.find(sequence => sequence.length === response.length);
|
|
1295
|
-
if (complete !== undefined) {
|
|
1296
|
-
validateCaptureResponse(request, response, frontier);
|
|
1297
|
-
for (const acceptedEvent of response) {
|
|
1298
|
-
await this.#applyResponseEvent(acceptedEvent);
|
|
1299
|
-
}
|
|
1300
|
-
return event;
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
async start(options: RecordingStartOptions): Promise<RecordingSnapshot> {
|
|
1306
|
-
if (this.#snapshot.state !== "idle") {
|
|
1307
|
-
throw new CliError("conflict", `Cannot start while recording is ${this.#snapshot.state}.`);
|
|
1308
|
-
}
|
|
1309
|
-
const recordingId = `rec_${this.#now().toISOString().replaceAll(/[-:.TZ]/gu, "")}_${randomUUID().slice(0, 8)}`;
|
|
1310
|
-
const recordingRoot = join(this.#artifactRoot, recordingId);
|
|
1311
|
-
this.#bundleWriter = undefined;
|
|
1312
|
-
this.#effectiveConfig = null;
|
|
1313
|
-
this.#failedSegmentCompletion = undefined;
|
|
1314
|
-
this.#pendingFailureInterruption = undefined;
|
|
1315
|
-
this.#sessionCompletionPersisted = false;
|
|
1316
|
-
await ensurePrivateDirectory(this.#artifactRoot);
|
|
1317
|
-
await mkdir(recordingRoot, { mode: 0o700, recursive: false });
|
|
1318
|
-
const effectiveConfig: EffectiveRecordingConfig = { ...options, metadata: true };
|
|
1319
|
-
const captureOptions: CaptureOptions = {
|
|
1320
|
-
camera: { ...options.camera },
|
|
1321
|
-
displays: options.displays.kind === "all"
|
|
1322
|
-
? { kind: "all" }
|
|
1323
|
-
: { displayIds: [...options.displays.displayIds], kind: "selected" },
|
|
1324
|
-
excludedBundleIdentifiers: ["com.hraness.slopcamera"],
|
|
1325
|
-
interactionEventProcessIdentifier:
|
|
1326
|
-
options.interactionEventProcessIdentifier ?? null,
|
|
1327
|
-
metadata: true,
|
|
1328
|
-
microphone: { ...options.microphone },
|
|
1329
|
-
strictSources: options.strictInputs,
|
|
1330
|
-
systemAudio: options.systemAudio,
|
|
1331
|
-
typedText: options.typedText,
|
|
1332
|
-
typedTextFocusIdentities:
|
|
1333
|
-
options.typedTextFocusIdentities === undefined
|
|
1334
|
-
|| options.typedTextFocusIdentities === null
|
|
1335
|
-
? null
|
|
1336
|
-
: options.typedTextFocusIdentities.map(identity => ({ ...identity })),
|
|
1337
|
-
};
|
|
1338
|
-
try {
|
|
1339
|
-
const configured = await this.#send({
|
|
1340
|
-
command: "configure",
|
|
1341
|
-
options: captureOptions,
|
|
1342
|
-
protocolVersion: CAPTURE_PROTOCOL_VERSION,
|
|
1343
|
-
requestId: this.#requestId("configure"),
|
|
1344
|
-
sessionDirectory: recordingRoot,
|
|
1345
|
-
});
|
|
1346
|
-
if (configured.event !== "configured") {
|
|
1347
|
-
throw new CliError("invalid-data", "Capture helper did not acknowledge configuration.");
|
|
1348
|
-
}
|
|
1349
|
-
this.#effectiveConfig = effectiveConfig;
|
|
1350
|
-
this.#bundleWriter = new CaptureBundleWriter({
|
|
1351
|
-
bundleRoot: recordingRoot,
|
|
1352
|
-
captureOptions,
|
|
1353
|
-
configured,
|
|
1354
|
-
helperVersion: this.#helperVersion,
|
|
1355
|
-
now: this.#now,
|
|
1356
|
-
recordingId,
|
|
1357
|
-
toolVersion: "3.2.7",
|
|
1358
|
-
verifier: this.#verifier,
|
|
1359
|
-
});
|
|
1360
|
-
await this.#bundleWriter.initialize();
|
|
1361
|
-
this.#snapshot = {
|
|
1362
|
-
availableSources: configured.availableSources,
|
|
1363
|
-
completedSegmentCount: 0,
|
|
1364
|
-
effectiveConfig,
|
|
1365
|
-
lastInterruption: configured.lastInterruption,
|
|
1366
|
-
logicalTimeUs: 0,
|
|
1367
|
-
permissions: permissionsFrom(configured),
|
|
1368
|
-
recordingId,
|
|
1369
|
-
recordingRoot,
|
|
1370
|
-
sources: configured.sources,
|
|
1371
|
-
state: "paused",
|
|
1372
|
-
updatedAt: this.#now().toISOString(),
|
|
1373
|
-
};
|
|
1374
|
-
const started = await this.#send({
|
|
1375
|
-
command: "start",
|
|
1376
|
-
protocolVersion: CAPTURE_PROTOCOL_VERSION,
|
|
1377
|
-
requestId: this.#requestId("start"),
|
|
1378
|
-
});
|
|
1379
|
-
if (started.event !== "segment-started") {
|
|
1380
|
-
throw new CliError("invalid-data", "Capture helper did not start a segment.");
|
|
1381
|
-
}
|
|
1382
|
-
await this.#bundleWriter.setCaptureEnvironment(
|
|
1383
|
-
started.permissions,
|
|
1384
|
-
started.sources,
|
|
1385
|
-
);
|
|
1386
|
-
this.#snapshot = {
|
|
1387
|
-
...this.#snapshot,
|
|
1388
|
-
logicalTimeUs: started.startUs,
|
|
1389
|
-
permissions: permissionsFrom(started),
|
|
1390
|
-
sources: started.sources,
|
|
1391
|
-
state: "recording",
|
|
1392
|
-
updatedAt: this.#now().toISOString(),
|
|
1393
|
-
};
|
|
1394
|
-
await this.#bundleWriter.setState("recording", started.startUs);
|
|
1395
|
-
await writeRepositoryRecordingState(this.#artifactRoot, this.#snapshot);
|
|
1396
|
-
return this.#snapshot;
|
|
1397
|
-
} catch (error) {
|
|
1398
|
-
if (this.#bundleWriter === undefined) {
|
|
1399
|
-
return await this.#discardUninitializedSession(recordingRoot, error);
|
|
1400
|
-
}
|
|
1401
|
-
return await this.#settleSessionFailure(error);
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
async pause(): Promise<RecordingSnapshot> {
|
|
1406
|
-
if (this.#snapshot.state !== "recording") {
|
|
1407
|
-
throw new CliError("conflict", `Cannot pause while recording is ${this.#snapshot.state}.`);
|
|
1408
|
-
}
|
|
1409
|
-
return await this.#runSessionMutation(async () => {
|
|
1410
|
-
const completed = await this.#send({
|
|
1411
|
-
command: "pause",
|
|
1412
|
-
protocolVersion: CAPTURE_PROTOCOL_VERSION,
|
|
1413
|
-
requestId: this.#requestId("pause"),
|
|
1414
|
-
});
|
|
1415
|
-
if (completed.event !== "segment-completed") {
|
|
1416
|
-
throw new CliError("invalid-data", "Capture helper did not complete the active segment.");
|
|
1417
|
-
}
|
|
1418
|
-
this.#snapshot = { ...this.#snapshot, state: "paused", updatedAt: this.#now().toISOString() };
|
|
1419
|
-
await this.#bundleWriter?.setState("paused", this.#snapshot.logicalTimeUs);
|
|
1420
|
-
await writeRepositoryRecordingState(this.#artifactRoot, this.#snapshot);
|
|
1421
|
-
return this.#snapshot;
|
|
1422
|
-
});
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
async resume(): Promise<RecordingSnapshot> {
|
|
1426
|
-
if (this.#snapshot.state === "idle" || this.#snapshot.recordingId === null) {
|
|
1427
|
-
throw new CliError("conflict", `Cannot resume while recording is ${this.#snapshot.state}.`);
|
|
1428
|
-
}
|
|
1429
|
-
return await this.#runSessionMutation(async () => {
|
|
1430
|
-
const started = await this.#send({
|
|
1431
|
-
command: "resume",
|
|
1432
|
-
protocolVersion: CAPTURE_PROTOCOL_VERSION,
|
|
1433
|
-
requestId: this.#requestId("resume"),
|
|
1434
|
-
});
|
|
1435
|
-
if (started.event !== "segment-started") {
|
|
1436
|
-
throw new CliError("invalid-data", "Capture helper did not resume capture.");
|
|
1437
|
-
}
|
|
1438
|
-
await this.#bundleWriter?.setCaptureEnvironment(
|
|
1439
|
-
started.permissions,
|
|
1440
|
-
started.sources,
|
|
1441
|
-
);
|
|
1442
|
-
this.#snapshot = {
|
|
1443
|
-
...this.#snapshot,
|
|
1444
|
-
logicalTimeUs: started.startUs,
|
|
1445
|
-
permissions: permissionsFrom(started),
|
|
1446
|
-
sources: started.sources,
|
|
1447
|
-
state: "recording",
|
|
1448
|
-
updatedAt: this.#now().toISOString(),
|
|
1449
|
-
};
|
|
1450
|
-
await this.#bundleWriter?.setState("recording", this.#snapshot.logicalTimeUs);
|
|
1451
|
-
await writeRepositoryRecordingState(this.#artifactRoot, this.#snapshot);
|
|
1452
|
-
return this.#snapshot;
|
|
1453
|
-
});
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
async status(): Promise<RecordingSnapshot> {
|
|
1457
|
-
if (this.#transport === undefined) {
|
|
1458
|
-
this.#snapshot = await readRepositoryRecordingState(this.#artifactRoot, this.#now);
|
|
1459
|
-
return this.#snapshot;
|
|
1460
|
-
}
|
|
1461
|
-
return await this.#runSessionMutation(async () => {
|
|
1462
|
-
const status = await this.#send({
|
|
1463
|
-
command: "status",
|
|
1464
|
-
protocolVersion: CAPTURE_PROTOCOL_VERSION,
|
|
1465
|
-
requestId: this.#requestId("status"),
|
|
1466
|
-
});
|
|
1467
|
-
if (status.event !== "status") throw new CliError("invalid-data", "Capture helper omitted status.");
|
|
1468
|
-
const state = status.state === "recording" ? "recording" : status.state === "paused" ? "paused" : "idle";
|
|
1469
|
-
if (status.state === "paused" || status.state === "stopped") {
|
|
1470
|
-
if (this.#bundleWriter === undefined) {
|
|
1471
|
-
throw new CliError("internal", `Capture helper reported ${status.state} without a recording bundle.`);
|
|
1472
|
-
}
|
|
1473
|
-
await this.#bundleWriter.setState(status.state, status.logicalTimeUs);
|
|
1474
|
-
}
|
|
1475
|
-
this.#snapshot = {
|
|
1476
|
-
...this.#snapshot,
|
|
1477
|
-
availableSources: status.availableSources,
|
|
1478
|
-
completedSegmentCount: status.completedSegmentCount,
|
|
1479
|
-
effectiveConfig: state === "idle" ? null : this.#effectiveConfig,
|
|
1480
|
-
lastInterruption: status.lastInterruption,
|
|
1481
|
-
logicalTimeUs: status.logicalTimeUs,
|
|
1482
|
-
permissions: permissionsFrom(status),
|
|
1483
|
-
recordingId: state === "idle" ? null : this.#snapshot.recordingId,
|
|
1484
|
-
recordingRoot: state === "idle" ? null : this.#snapshot.recordingRoot,
|
|
1485
|
-
sources: status.sources,
|
|
1486
|
-
state,
|
|
1487
|
-
updatedAt: this.#now().toISOString(),
|
|
1488
|
-
};
|
|
1489
|
-
await writeRepositoryRecordingState(this.#artifactRoot, this.#snapshot);
|
|
1490
|
-
return this.#snapshot;
|
|
1491
|
-
});
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
async stop(): Promise<RecordingSnapshot> {
|
|
1495
|
-
if (this.#snapshot.state === "idle") throw new CliError("conflict", "No recording is active.");
|
|
1496
|
-
return await this.#runSessionMutation(async () => {
|
|
1497
|
-
const completed = await this.#send({
|
|
1498
|
-
command: "stop",
|
|
1499
|
-
protocolVersion: CAPTURE_PROTOCOL_VERSION,
|
|
1500
|
-
requestId: this.#requestId("stop"),
|
|
1501
|
-
});
|
|
1502
|
-
if (completed.event !== "session-completed") {
|
|
1503
|
-
throw new CliError("invalid-data", "Capture helper did not complete the session.");
|
|
1504
|
-
}
|
|
1505
|
-
const stopped: RecordingSnapshot = {
|
|
1506
|
-
...this.#snapshot,
|
|
1507
|
-
completedSegmentCount: completed.segmentCount,
|
|
1508
|
-
logicalTimeUs: completed.durationUs,
|
|
1509
|
-
state: "idle",
|
|
1510
|
-
updatedAt: this.#now().toISOString(),
|
|
1511
|
-
};
|
|
1512
|
-
this.#snapshot = stopped;
|
|
1513
|
-
await rm(activeStatePath(this.#artifactRoot), { force: true });
|
|
1514
|
-
return stopped;
|
|
1515
|
-
});
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
async close(): Promise<void> {
|
|
1519
|
-
if (this.#closed) return;
|
|
1520
|
-
const transport = this.#transport;
|
|
1521
|
-
if (transport === undefined) {
|
|
1522
|
-
this.#closed = true;
|
|
1523
|
-
return;
|
|
1524
|
-
}
|
|
1525
|
-
try {
|
|
1526
|
-
try {
|
|
1527
|
-
const sessionNeedsCompletion = this.#bundleWriter !== undefined && !this.#sessionCompletionPersisted;
|
|
1528
|
-
const event = await this.#send({
|
|
1529
|
-
command: "shutdown",
|
|
1530
|
-
protocolVersion: CAPTURE_PROTOCOL_VERSION,
|
|
1531
|
-
requestId: this.#requestId("shutdown"),
|
|
1532
|
-
});
|
|
1533
|
-
if (event.event !== "shutdown") {
|
|
1534
|
-
throw new CliError("invalid-data", `Capture helper emitted ${event.event} while shutting down.`);
|
|
1535
|
-
}
|
|
1536
|
-
if (sessionNeedsCompletion && !this.#sessionCompletionPersisted) {
|
|
1537
|
-
throw new CliError("invalid-data", "Capture helper shut down without completing the active session.");
|
|
1538
|
-
}
|
|
1539
|
-
if (this.#sessionCompletionPersisted) {
|
|
1540
|
-
await rm(activeStatePath(this.#artifactRoot), { force: true });
|
|
1541
|
-
}
|
|
1542
|
-
} catch (error) {
|
|
1543
|
-
return await this.#settleSessionFailure(error);
|
|
1544
|
-
}
|
|
1545
|
-
} finally {
|
|
1546
|
-
this.#closed = true;
|
|
1547
|
-
this.#transport = undefined;
|
|
1548
|
-
await transport.close();
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
export async function executeRecordingAction(
|
|
1554
|
-
controller: RecordingController,
|
|
1555
|
-
action: "start" | "pause" | "resume" | "stop" | "status",
|
|
1556
|
-
options?: RecordingStartOptions,
|
|
1557
|
-
): Promise<RecordingSnapshot> {
|
|
1558
|
-
switch (action) {
|
|
1559
|
-
case "start":
|
|
1560
|
-
if (options === undefined) throw new CliError("internal", "Start options are missing.");
|
|
1561
|
-
return await controller.start(options);
|
|
1562
|
-
case "pause": return await controller.pause();
|
|
1563
|
-
case "resume": return await controller.resume();
|
|
1564
|
-
case "stop": return await controller.stop();
|
|
1565
|
-
case "status": return await controller.status();
|
|
1566
|
-
}
|
|
1567
|
-
}
|