@deepwatch/dsh-live 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 +21 -0
- package/README.md +87 -0
- package/lib/capture.d.ts +155 -0
- package/lib/capture.js +301 -0
- package/lib/client/components.d.ts +55 -0
- package/lib/client/components.js +62 -0
- package/lib/client/index.d.ts +14 -0
- package/lib/client/index.js +22 -0
- package/lib/client/live-mode.d.ts +38 -0
- package/lib/client/live-mode.js +72 -0
- package/lib/client.js +951 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +22 -0
- package/lib/index.js +22 -0
- package/lib/session.d.ts +216 -0
- package/lib/session.js +280 -0
- package/lib/sources-catalogue.d.ts +66 -0
- package/lib/sources-catalogue.js +110 -0
- package/lib/synthetic-source.d.ts +58 -0
- package/lib/synthetic-source.js +96 -0
- package/lib/triggers.d.ts +114 -0
- package/lib/triggers.js +138 -0
- package/package.json +97 -0
package/lib/session.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live: watching something while it is still happening.
|
|
3
|
+
*
|
|
4
|
+
* The engine already produces live observations. What did not exist was the
|
|
5
|
+
* client half — and the client half is where every interesting failure lives,
|
|
6
|
+
* because a live surface has to be correct about time it did not see.
|
|
7
|
+
*
|
|
8
|
+
* Four decisions carry this module.
|
|
9
|
+
*
|
|
10
|
+
* **A cursor is the only ordering authority.** Events are read by cursor, and
|
|
11
|
+
* a repeated cursor returns the same events. That makes a retry free: a caller
|
|
12
|
+
* that times out, reconnects and re-asks cannot lose an event or receive one
|
|
13
|
+
* twice, which is what makes a flaky network a nuisance instead of a source of
|
|
14
|
+
* fabricated history.
|
|
15
|
+
*
|
|
16
|
+
* **A gap is a fact, not a rendering problem.** When a delta does not continue
|
|
17
|
+
* from the cursor the client holds, the client does not interpolate, does not
|
|
18
|
+
* guess, and does not quietly skip forward. It records a gap with its own
|
|
19
|
+
* range and asks for a fresh snapshot. The gap stays in the buffer afterwards.
|
|
20
|
+
* Every alternative — smoothing, backfilling, "probably nothing happened" —
|
|
21
|
+
* converts missing evidence into evidence, which is the one thing this product
|
|
22
|
+
* cannot do.
|
|
23
|
+
*
|
|
24
|
+
* **Three clocks, never one.** Wall clock, media clock and session clock
|
|
25
|
+
* disagree constantly during a live observation, and each answers a different
|
|
26
|
+
* question: when did this happen in the world, where is it in the source, and
|
|
27
|
+
* how far into watching are we. Collapsing them produces a subtitle attributed
|
|
28
|
+
* to the wrong second.
|
|
29
|
+
*
|
|
30
|
+
* **The buffer is bounded.** A session left running for eight hours must not
|
|
31
|
+
* grow a browser tab without limit. Trimming drops the oldest ordinary events
|
|
32
|
+
* and keeps three things forever: gaps, pinned moments, and the count of what
|
|
33
|
+
* was trimmed. A buffer that silently forgot a gap would let a long session
|
|
34
|
+
* end up looking cleaner than a short one.
|
|
35
|
+
*
|
|
36
|
+
* @module @deepwatch/dsh-live/session
|
|
37
|
+
*/
|
|
38
|
+
/** The default bound. Roughly an hour of dense observation. */
|
|
39
|
+
export const DEFAULT_LIMITS = { maxEvents: 2000 };
|
|
40
|
+
/** Begin a session, before anything has been observed. */
|
|
41
|
+
export function startSession(input) {
|
|
42
|
+
return {
|
|
43
|
+
sessionId: input.sessionId,
|
|
44
|
+
target: input.target,
|
|
45
|
+
kind: input.kind,
|
|
46
|
+
status: 'starting',
|
|
47
|
+
connection: 'connecting',
|
|
48
|
+
cursor: '',
|
|
49
|
+
startedAtMs: input.startedAtMs,
|
|
50
|
+
events: [],
|
|
51
|
+
gaps: [],
|
|
52
|
+
pinned: [],
|
|
53
|
+
clocks: { wallMs: null, mediaMs: null, sessionMs: 0, latencyMs: null },
|
|
54
|
+
trimmed: 0,
|
|
55
|
+
needsSnapshot: false,
|
|
56
|
+
lastError: null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Recompute the clocks from the newest event. */
|
|
60
|
+
function clocksFrom(state, events, nowMs) {
|
|
61
|
+
const newest = events[events.length - 1];
|
|
62
|
+
if (newest === undefined) {
|
|
63
|
+
return { wallMs: null, mediaMs: null, sessionMs: nowMs - state.startedAtMs, latencyMs: null };
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
wallMs: newest.at,
|
|
67
|
+
mediaMs: newest.mediaMs,
|
|
68
|
+
sessionMs: nowMs - state.startedAtMs,
|
|
69
|
+
// Never negative. A clock skew that made the newest event look like it
|
|
70
|
+
// arrived from the future would render as a nonsense latency.
|
|
71
|
+
latencyMs: Math.max(0, nowMs - newest.at),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Trim the buffer to its bound.
|
|
76
|
+
*
|
|
77
|
+
* Gaps and pinned events survive. The trim count is carried so the surface can
|
|
78
|
+
* say "1,204 earlier events dropped" rather than presenting a partial history
|
|
79
|
+
* as a complete one.
|
|
80
|
+
*/
|
|
81
|
+
function bound(events, pinned, limits) {
|
|
82
|
+
if (events.length <= limits.maxEvents)
|
|
83
|
+
return { events, dropped: 0 };
|
|
84
|
+
const pinnedEvidence = new Set(pinned.flatMap(moment => moment.evidenceIds));
|
|
85
|
+
const keepAlways = (event) => event.kind === 'gap' || event.evidenceIds.some(id => pinnedEvidence.has(id));
|
|
86
|
+
const protectedEvents = events.filter(keepAlways);
|
|
87
|
+
const ordinary = events.filter(event => !keepAlways(event));
|
|
88
|
+
const room = Math.max(0, limits.maxEvents - protectedEvents.length);
|
|
89
|
+
const kept = ordinary.slice(Math.max(0, ordinary.length - room));
|
|
90
|
+
const keptIds = new Set([...protectedEvents, ...kept].map(event => event.seq));
|
|
91
|
+
return {
|
|
92
|
+
events: events.filter(event => keptIds.has(event.seq)),
|
|
93
|
+
dropped: ordinary.length - kept.length,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Mint the gap a cursor break implies, with the range it actually covers. */
|
|
97
|
+
function cursorBreakGap(state, delta) {
|
|
98
|
+
const last = state.events[state.events.length - 1];
|
|
99
|
+
const next = delta.events[0];
|
|
100
|
+
if (last === undefined || next === undefined)
|
|
101
|
+
return null;
|
|
102
|
+
return {
|
|
103
|
+
seq: last.seq + 0.5,
|
|
104
|
+
cursor: state.cursor,
|
|
105
|
+
kind: 'gap',
|
|
106
|
+
at: last.at,
|
|
107
|
+
mediaMs: last.mediaMs,
|
|
108
|
+
text: 'observation gap — the stream did not continue from the last cursor',
|
|
109
|
+
range: last.mediaMs === null || next.mediaMs === null
|
|
110
|
+
? null
|
|
111
|
+
: { startMs: last.mediaMs, endMs: next.mediaMs },
|
|
112
|
+
evidenceIds: [],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Apply one delta or snapshot to the session.
|
|
117
|
+
*
|
|
118
|
+
* The three cases, in the order they are checked:
|
|
119
|
+
*
|
|
120
|
+
* 1. **A snapshot** replaces the event view outright and clears
|
|
121
|
+
* `needsSnapshot`. This is the only thing that re-establishes continuity.
|
|
122
|
+
* 2. **A delta that continues from the held cursor** appends. This is the
|
|
123
|
+
* ordinary case.
|
|
124
|
+
* 3. **A delta that does not** is a break. A gap event is minted at the seam,
|
|
125
|
+
* `needsSnapshot` is set, and the delta's events are *not* appended —
|
|
126
|
+
* appending them would splice two discontinuous stretches into something
|
|
127
|
+
* that reads as continuous.
|
|
128
|
+
*
|
|
129
|
+
* Re-applying the same delta is a no-op, which is what makes a retry safe.
|
|
130
|
+
*/
|
|
131
|
+
export function applyDelta(state, delta, nowMs, limits = DEFAULT_LIMITS) {
|
|
132
|
+
const engineGaps = delta.gaps ?? [];
|
|
133
|
+
if (delta.isSnapshot) {
|
|
134
|
+
const { events, dropped } = bound(delta.events, state.pinned, limits);
|
|
135
|
+
return {
|
|
136
|
+
...state,
|
|
137
|
+
status: delta.status,
|
|
138
|
+
connection: delta.status === 'observing' ? 'live' : state.connection,
|
|
139
|
+
cursor: delta.nextCursor,
|
|
140
|
+
events,
|
|
141
|
+
gaps: mergeGaps(state.gaps, [...engineGaps, ...rangesOf(delta.events)]),
|
|
142
|
+
clocks: clocksFrom(state, events, nowMs),
|
|
143
|
+
trimmed: state.trimmed + dropped,
|
|
144
|
+
needsSnapshot: false,
|
|
145
|
+
lastError: null,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// An already-applied delta. Returning the same state is what makes a retry
|
|
149
|
+
// after a timeout free rather than a source of duplicates.
|
|
150
|
+
if (delta.nextCursor === state.cursor)
|
|
151
|
+
return state;
|
|
152
|
+
if (delta.fromCursor !== state.cursor) {
|
|
153
|
+
const gap = cursorBreakGap(state, delta);
|
|
154
|
+
const events = gap === null ? state.events : [...state.events, gap];
|
|
155
|
+
return {
|
|
156
|
+
...state,
|
|
157
|
+
connection: 'reconnecting',
|
|
158
|
+
events,
|
|
159
|
+
gaps: mergeGaps(state.gaps, gap?.range === null || gap?.range === undefined ? [] : [gap.range]),
|
|
160
|
+
clocks: clocksFrom(state, events, nowMs),
|
|
161
|
+
needsSnapshot: true,
|
|
162
|
+
lastError: `cursor ${delta.fromCursor} does not continue from `
|
|
163
|
+
+ (state.cursor === '' ? '(start)' : state.cursor),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const appended = [...state.events, ...delta.events];
|
|
167
|
+
const { events, dropped } = bound(appended, state.pinned, limits);
|
|
168
|
+
return {
|
|
169
|
+
...state,
|
|
170
|
+
status: delta.status,
|
|
171
|
+
connection: delta.status === 'observing' ? 'live' : state.connection,
|
|
172
|
+
cursor: delta.nextCursor,
|
|
173
|
+
events,
|
|
174
|
+
gaps: mergeGaps(state.gaps, [...engineGaps, ...rangesOf(delta.events)]),
|
|
175
|
+
clocks: clocksFrom(state, events, nowMs),
|
|
176
|
+
trimmed: state.trimmed + dropped,
|
|
177
|
+
lastError: null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** Ranges of the gap events in a batch. */
|
|
181
|
+
function rangesOf(events) {
|
|
182
|
+
return events
|
|
183
|
+
.filter(event => event.kind === 'gap' && event.range !== null)
|
|
184
|
+
.map(event => event.range);
|
|
185
|
+
}
|
|
186
|
+
/** Union of two gap lists, deduplicated and ordered. */
|
|
187
|
+
function mergeGaps(existing, incoming) {
|
|
188
|
+
const seen = new Map();
|
|
189
|
+
for (const range of [...existing, ...incoming]) {
|
|
190
|
+
seen.set(`${String(range.startMs)}:${String(range.endMs)}`, range);
|
|
191
|
+
}
|
|
192
|
+
return [...seen.values()].sort((left, right) => left.startMs - right.startMs);
|
|
193
|
+
}
|
|
194
|
+
/** Record that the channel dropped. */
|
|
195
|
+
export function connectionLost(state, reason) {
|
|
196
|
+
return {
|
|
197
|
+
...state,
|
|
198
|
+
connection: 'lost',
|
|
199
|
+
// A lost connection always needs a snapshot on return. Even if no event
|
|
200
|
+
// was missed, the client cannot know that, and assuming it is how a gap
|
|
201
|
+
// goes unrecorded.
|
|
202
|
+
needsSnapshot: true,
|
|
203
|
+
lastError: reason,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Record that a reconnect attempt is under way. */
|
|
207
|
+
export function reconnecting(state) {
|
|
208
|
+
return { ...state, connection: 'reconnecting' };
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Pin a moment.
|
|
212
|
+
*
|
|
213
|
+
* Pinning protects the events it references from being trimmed. That is the
|
|
214
|
+
* whole reason pins take evidence ids: a pin that pointed only at a timestamp
|
|
215
|
+
* would survive a trim while the thing it pointed at did not.
|
|
216
|
+
*/
|
|
217
|
+
export function pinMoment(state, moment) {
|
|
218
|
+
if (state.pinned.some(existing => existing.momentId === moment.momentId))
|
|
219
|
+
return state;
|
|
220
|
+
return { ...state, pinned: [...state.pinned, moment] };
|
|
221
|
+
}
|
|
222
|
+
/** Stop the session, keeping or discarding what it observed. */
|
|
223
|
+
export function finish(state, finalize) {
|
|
224
|
+
return {
|
|
225
|
+
...state,
|
|
226
|
+
status: finalize ? 'finalized' : 'discarded',
|
|
227
|
+
connection: 'stopped',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** Freeze a finished session into its replay record. */
|
|
231
|
+
export function toReplay(state) {
|
|
232
|
+
return {
|
|
233
|
+
sessionId: state.sessionId,
|
|
234
|
+
target: state.target,
|
|
235
|
+
kind: state.kind,
|
|
236
|
+
status: state.status,
|
|
237
|
+
events: state.events,
|
|
238
|
+
gaps: state.gaps,
|
|
239
|
+
pinned: state.pinned,
|
|
240
|
+
trimmed: state.trimmed,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Digest of a replay, so "reopening it shows the same thing" is assertable.
|
|
245
|
+
*
|
|
246
|
+
* FNV-1a over event identity and gap ranges — the same construction the
|
|
247
|
+
* trajectory projection uses, and for the same reason.
|
|
248
|
+
*/
|
|
249
|
+
export function replayDigest(replay) {
|
|
250
|
+
let hash = 0x811c9dc5;
|
|
251
|
+
const feed = (text) => {
|
|
252
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
253
|
+
hash ^= text.charCodeAt(index);
|
|
254
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
feed(`${replay.sessionId}|${replay.status}|${String(replay.trimmed)}`);
|
|
258
|
+
for (const event of replay.events)
|
|
259
|
+
feed(`|${String(event.seq)}:${event.kind}:${event.cursor}`);
|
|
260
|
+
for (const gap of replay.gaps)
|
|
261
|
+
feed(`|gap:${String(gap.startMs)}-${String(gap.endMs)}`);
|
|
262
|
+
return hash.toString(16).padStart(8, '0');
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* One line describing the session's honesty, for the surface header.
|
|
266
|
+
*
|
|
267
|
+
* Always says the gap count and the trim count, including when both are zero.
|
|
268
|
+
* A message that appears only when something is wrong is a message people
|
|
269
|
+
* learn to not look for.
|
|
270
|
+
*/
|
|
271
|
+
export function describeContinuity(state) {
|
|
272
|
+
const parts = [
|
|
273
|
+
state.gaps.length === 0 ? 'no gaps' : `${String(state.gaps.length)} gap(s)`,
|
|
274
|
+
state.trimmed === 0 ? 'nothing dropped' : `${String(state.trimmed)} earlier event(s) dropped`,
|
|
275
|
+
];
|
|
276
|
+
if (state.needsSnapshot)
|
|
277
|
+
parts.push('view not continuous — resnapshot pending');
|
|
278
|
+
return parts.join(' · ');
|
|
279
|
+
}
|
|
280
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sources Live can offer, and what each one honestly is.
|
|
3
|
+
*
|
|
4
|
+
* A source appears here whether or not this machine can run it, because a
|
|
5
|
+
* catalogue that hid unavailable sources would answer the wrong question: a
|
|
6
|
+
* person wants to know what the product can observe *and* why it cannot observe
|
|
7
|
+
* it here. Hiding the second is how "the feature does not exist" and "your
|
|
8
|
+
* machine cannot do it" become indistinguishable.
|
|
9
|
+
*
|
|
10
|
+
* Two entries deserve their separation. **Browser Observer** watches a page and
|
|
11
|
+
* records what it showed; **Browser Operator** acts on one and returns a
|
|
12
|
+
* receipt for what it did. They are listed apart because they are apart: one
|
|
13
|
+
* capability that covered both would grant the power to act while a person
|
|
14
|
+
* believed they were enabling the power to watch, and no amount of wording in a
|
|
15
|
+
* tooltip fixes a control that does two things.
|
|
16
|
+
*
|
|
17
|
+
* @module @deepwatch/dsh-live/sources-catalogue
|
|
18
|
+
*/
|
|
19
|
+
import type { SourceAvailability } from './capture.js';
|
|
20
|
+
/** How a source is reached, which decides what it needs to run. */
|
|
21
|
+
export type SourceRuntime =
|
|
22
|
+
/** Provided by the browser or the Electron shell. */
|
|
23
|
+
'shell'
|
|
24
|
+
/** A device attached to this machine. */
|
|
25
|
+
| 'device'
|
|
26
|
+
/** A process this machine starts. */
|
|
27
|
+
| 'process'
|
|
28
|
+
/** Synthetic, for tests. Talks to nothing. */
|
|
29
|
+
| 'fixture';
|
|
30
|
+
export interface LiveSource {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly name: string;
|
|
33
|
+
/** What it observes, in a sentence. */
|
|
34
|
+
readonly what: string;
|
|
35
|
+
/** When it would ask, so nobody is surprised by a prompt. */
|
|
36
|
+
readonly asks: string;
|
|
37
|
+
readonly runtime: SourceRuntime;
|
|
38
|
+
/** Whether using it needs an OS permission at all. */
|
|
39
|
+
readonly needsOsPermission: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Whether it can act on the world rather than only record it.
|
|
42
|
+
*
|
|
43
|
+
* Exactly one source is `true`, and it is the one whose name says so.
|
|
44
|
+
*/
|
|
45
|
+
readonly canAct: boolean;
|
|
46
|
+
}
|
|
47
|
+
export declare const SOURCES: readonly LiveSource[];
|
|
48
|
+
/** One source by id, or undefined. */
|
|
49
|
+
export declare function sourceById(id: string): LiveSource | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* A short, honest word for how a source stands right now.
|
|
52
|
+
*
|
|
53
|
+
* Availability is a runtime fact, so the catalogue never claims it. Without an
|
|
54
|
+
* adapter's probe, the truthful answer is that nothing has been started —
|
|
55
|
+
* which is different from unavailable, and different again from broken.
|
|
56
|
+
*/
|
|
57
|
+
export declare function describeAvailability(availability?: SourceAvailability): string;
|
|
58
|
+
/**
|
|
59
|
+
* The synthetic adapter's availability.
|
|
60
|
+
*
|
|
61
|
+
* Always available, because it invents its own content and reaches nothing.
|
|
62
|
+
* This is the source the end-to-end capture test uses: exercising the real
|
|
63
|
+
* lifecycle without capturing a single pixel of anybody's actual screen.
|
|
64
|
+
*/
|
|
65
|
+
export declare function syntheticAvailability(): SourceAvailability;
|
|
66
|
+
//# sourceMappingURL=sources-catalogue.d.ts.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sources Live can offer, and what each one honestly is.
|
|
3
|
+
*
|
|
4
|
+
* A source appears here whether or not this machine can run it, because a
|
|
5
|
+
* catalogue that hid unavailable sources would answer the wrong question: a
|
|
6
|
+
* person wants to know what the product can observe *and* why it cannot observe
|
|
7
|
+
* it here. Hiding the second is how "the feature does not exist" and "your
|
|
8
|
+
* machine cannot do it" become indistinguishable.
|
|
9
|
+
*
|
|
10
|
+
* Two entries deserve their separation. **Browser Observer** watches a page and
|
|
11
|
+
* records what it showed; **Browser Operator** acts on one and returns a
|
|
12
|
+
* receipt for what it did. They are listed apart because they are apart: one
|
|
13
|
+
* capability that covered both would grant the power to act while a person
|
|
14
|
+
* believed they were enabling the power to watch, and no amount of wording in a
|
|
15
|
+
* tooltip fixes a control that does two things.
|
|
16
|
+
*
|
|
17
|
+
* @module @deepwatch/dsh-live/sources-catalogue
|
|
18
|
+
*/
|
|
19
|
+
export const SOURCES = [
|
|
20
|
+
{
|
|
21
|
+
id: 'screen',
|
|
22
|
+
name: 'Screen',
|
|
23
|
+
what: 'The whole display, as a continuous observation.',
|
|
24
|
+
asks: 'Asks for screen-capture permission the first time you start it.',
|
|
25
|
+
runtime: 'shell',
|
|
26
|
+
needsOsPermission: true,
|
|
27
|
+
canAct: false,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'window',
|
|
31
|
+
name: 'Window',
|
|
32
|
+
what: 'One application window rather than the whole display.',
|
|
33
|
+
asks: 'Asks for screen-capture permission the first time you start it.',
|
|
34
|
+
runtime: 'shell',
|
|
35
|
+
needsOsPermission: true,
|
|
36
|
+
canAct: false,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'camera',
|
|
40
|
+
name: 'Camera',
|
|
41
|
+
what: 'Live visual input from an attached device.',
|
|
42
|
+
asks: 'Asks for camera permission the first time you start it.',
|
|
43
|
+
runtime: 'device',
|
|
44
|
+
needsOsPermission: true,
|
|
45
|
+
canAct: false,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'microphone',
|
|
49
|
+
name: 'Microphone',
|
|
50
|
+
what: 'Live audio, with timings a citation can point at.',
|
|
51
|
+
asks: 'Asks for microphone permission the first time you start it.',
|
|
52
|
+
runtime: 'device',
|
|
53
|
+
needsOsPermission: true,
|
|
54
|
+
canAct: false,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'browser-observer',
|
|
58
|
+
name: 'Browser Observer',
|
|
59
|
+
what: 'Watches a page and records what it showed. Takes no action.',
|
|
60
|
+
asks: 'No OS permission. Needs the browser runtime enabled.',
|
|
61
|
+
runtime: 'process',
|
|
62
|
+
needsOsPermission: false,
|
|
63
|
+
canAct: false,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'browser-operator',
|
|
67
|
+
name: 'Browser Operator',
|
|
68
|
+
what: 'Acts on a page and returns a receipt for what it did. A separate capability from observing.',
|
|
69
|
+
asks: 'No OS permission. Needs the browser runtime enabled, and every side effect carries an idempotency key.',
|
|
70
|
+
runtime: 'process',
|
|
71
|
+
needsOsPermission: false,
|
|
72
|
+
canAct: true,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'synthetic',
|
|
76
|
+
name: 'Synthetic source',
|
|
77
|
+
what: 'A task-owned page this workspace generates, for exercising capture without touching anything of yours.',
|
|
78
|
+
asks: 'No permission. It observes only content this workspace created.',
|
|
79
|
+
runtime: 'fixture',
|
|
80
|
+
needsOsPermission: false,
|
|
81
|
+
canAct: false,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
/** One source by id, or undefined. */
|
|
85
|
+
export function sourceById(id) {
|
|
86
|
+
return SOURCES.find(source => source.id === id);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A short, honest word for how a source stands right now.
|
|
90
|
+
*
|
|
91
|
+
* Availability is a runtime fact, so the catalogue never claims it. Without an
|
|
92
|
+
* adapter's probe, the truthful answer is that nothing has been started —
|
|
93
|
+
* which is different from unavailable, and different again from broken.
|
|
94
|
+
*/
|
|
95
|
+
export function describeAvailability(availability) {
|
|
96
|
+
if (availability === undefined)
|
|
97
|
+
return 'Not started';
|
|
98
|
+
return availability.available ? 'Available' : availability.reason;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The synthetic adapter's availability.
|
|
102
|
+
*
|
|
103
|
+
* Always available, because it invents its own content and reaches nothing.
|
|
104
|
+
* This is the source the end-to-end capture test uses: exercising the real
|
|
105
|
+
* lifecycle without capturing a single pixel of anybody's actual screen.
|
|
106
|
+
*/
|
|
107
|
+
export function syntheticAvailability() {
|
|
108
|
+
return { available: true, reason: '' };
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=sources-catalogue.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A capture source that observes only what this workspace made.
|
|
3
|
+
*
|
|
4
|
+
* The end-to-end capture test needs a real source: something that genuinely
|
|
5
|
+
* starts, genuinely emits timestamped observations, and genuinely releases what
|
|
6
|
+
* it held. Pointing that test at a screen or a camera would capture whatever
|
|
7
|
+
* happened to be in front of the person running it — their email, their
|
|
8
|
+
* terminal, their face — and put it in a fixture directory. That is not a test
|
|
9
|
+
* anyone should run twice.
|
|
10
|
+
*
|
|
11
|
+
* So the source generates its own content on a deterministic clock and observes
|
|
12
|
+
* that. It exercises every state the real adapters do — probe, permission,
|
|
13
|
+
* start, emit, stop, teardown — while reading nothing it did not write.
|
|
14
|
+
*
|
|
15
|
+
* It is not a mock of an adapter. It is an adapter, of a source that happens to
|
|
16
|
+
* be synthetic, which is why the lifecycle it exercises is the real one.
|
|
17
|
+
*
|
|
18
|
+
* @module @deepwatch/dsh-live/synthetic-source
|
|
19
|
+
*/
|
|
20
|
+
import type { CaptureAdapter, Observation, SourceAvailability } from './capture.js';
|
|
21
|
+
export interface SyntheticOptions {
|
|
22
|
+
/** Lines to emit, in order. The content this source will observe. */
|
|
23
|
+
readonly script: readonly string[];
|
|
24
|
+
/** Milliseconds between emissions. Small so tests stay fast. */
|
|
25
|
+
readonly intervalMs?: number;
|
|
26
|
+
/** Refuse permission, to exercise the denial path. */
|
|
27
|
+
readonly refusePermission?: boolean;
|
|
28
|
+
/** Report unavailable, to exercise the missing-source path. */
|
|
29
|
+
readonly unavailable?: string;
|
|
30
|
+
/** Throw on start, to exercise the failure path. */
|
|
31
|
+
readonly failOnStart?: boolean;
|
|
32
|
+
/** Never signal started, to exercise the timeout path. */
|
|
33
|
+
readonly hangOnStart?: boolean;
|
|
34
|
+
readonly now?: () => Date;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A source that writes its own content and then observes it.
|
|
38
|
+
*
|
|
39
|
+
* `emitted` and `released` are exposed so a test can assert the things that
|
|
40
|
+
* matter most and are hardest to see: that every timer was cleared, and that
|
|
41
|
+
* teardown ran exactly once however the session ended.
|
|
42
|
+
*/
|
|
43
|
+
export declare class SyntheticSource implements CaptureAdapter {
|
|
44
|
+
#private;
|
|
45
|
+
readonly sourceId = "synthetic";
|
|
46
|
+
constructor(options: SyntheticOptions);
|
|
47
|
+
/** How many observations it produced. */
|
|
48
|
+
get emitted(): number;
|
|
49
|
+
/** How many times teardown ran. Must never exceed one. */
|
|
50
|
+
get releases(): number;
|
|
51
|
+
/** Whether anything is still scheduled. Must be false after any ending. */
|
|
52
|
+
get running(): boolean;
|
|
53
|
+
probe(): SourceAvailability;
|
|
54
|
+
requestPermission(): boolean;
|
|
55
|
+
start(emit: (observation: Observation) => void): Promise<void>;
|
|
56
|
+
stop(): void;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=synthetic-source.d.ts.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A capture source that observes only what this workspace made.
|
|
3
|
+
*
|
|
4
|
+
* The end-to-end capture test needs a real source: something that genuinely
|
|
5
|
+
* starts, genuinely emits timestamped observations, and genuinely releases what
|
|
6
|
+
* it held. Pointing that test at a screen or a camera would capture whatever
|
|
7
|
+
* happened to be in front of the person running it — their email, their
|
|
8
|
+
* terminal, their face — and put it in a fixture directory. That is not a test
|
|
9
|
+
* anyone should run twice.
|
|
10
|
+
*
|
|
11
|
+
* So the source generates its own content on a deterministic clock and observes
|
|
12
|
+
* that. It exercises every state the real adapters do — probe, permission,
|
|
13
|
+
* start, emit, stop, teardown — while reading nothing it did not write.
|
|
14
|
+
*
|
|
15
|
+
* It is not a mock of an adapter. It is an adapter, of a source that happens to
|
|
16
|
+
* be synthetic, which is why the lifecycle it exercises is the real one.
|
|
17
|
+
*
|
|
18
|
+
* @module @deepwatch/dsh-live/synthetic-source
|
|
19
|
+
*/
|
|
20
|
+
import { observationAt } from './capture.js';
|
|
21
|
+
import { syntheticAvailability } from './sources-catalogue.js';
|
|
22
|
+
/**
|
|
23
|
+
* A source that writes its own content and then observes it.
|
|
24
|
+
*
|
|
25
|
+
* `emitted` and `released` are exposed so a test can assert the things that
|
|
26
|
+
* matter most and are hardest to see: that every timer was cleared, and that
|
|
27
|
+
* teardown ran exactly once however the session ended.
|
|
28
|
+
*/
|
|
29
|
+
export class SyntheticSource {
|
|
30
|
+
sourceId = 'synthetic';
|
|
31
|
+
#options;
|
|
32
|
+
#timer = null;
|
|
33
|
+
#emitted = 0;
|
|
34
|
+
#releases = 0;
|
|
35
|
+
#base = null;
|
|
36
|
+
#now;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.#options = options;
|
|
39
|
+
this.#now = options.now ?? (() => new Date());
|
|
40
|
+
}
|
|
41
|
+
/** How many observations it produced. */
|
|
42
|
+
get emitted() { return this.#emitted; }
|
|
43
|
+
/** How many times teardown ran. Must never exceed one. */
|
|
44
|
+
get releases() { return this.#releases; }
|
|
45
|
+
/** Whether anything is still scheduled. Must be false after any ending. */
|
|
46
|
+
get running() { return this.#timer !== null; }
|
|
47
|
+
probe() {
|
|
48
|
+
if (this.#options.unavailable !== undefined) {
|
|
49
|
+
return { available: false, reason: this.#options.unavailable };
|
|
50
|
+
}
|
|
51
|
+
return syntheticAvailability();
|
|
52
|
+
}
|
|
53
|
+
requestPermission() {
|
|
54
|
+
// A synthetic source still goes through the permission gate. Skipping it
|
|
55
|
+
// because "it is only a fixture" would mean the lifecycle the test proves
|
|
56
|
+
// is not the lifecycle that ships.
|
|
57
|
+
return this.#options.refusePermission !== true;
|
|
58
|
+
}
|
|
59
|
+
async start(emit) {
|
|
60
|
+
if (this.#options.failOnStart === true)
|
|
61
|
+
throw new Error('the synthetic source was asked to fail');
|
|
62
|
+
if (this.#options.hangOnStart === true) {
|
|
63
|
+
// Resolve never. The session's own timeout is what has to save it, and
|
|
64
|
+
// that is precisely what this exercises.
|
|
65
|
+
return new Promise(() => { });
|
|
66
|
+
}
|
|
67
|
+
this.#base = this.#now();
|
|
68
|
+
const interval = this.#options.intervalMs ?? 5;
|
|
69
|
+
let index = 0;
|
|
70
|
+
await new Promise(resolve => {
|
|
71
|
+
this.#timer = setInterval(() => {
|
|
72
|
+
const line = this.#options.script[index];
|
|
73
|
+
if (line === undefined) {
|
|
74
|
+
this.#clear();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
emit(observationAt(this.#base ?? this.#now(), this.#now(), 'text', line, index));
|
|
78
|
+
this.#emitted += 1;
|
|
79
|
+
index += 1;
|
|
80
|
+
}, interval);
|
|
81
|
+
// Started means "producing", not "finished producing".
|
|
82
|
+
resolve();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
stop() {
|
|
86
|
+
this.#clear();
|
|
87
|
+
this.#releases += 1;
|
|
88
|
+
}
|
|
89
|
+
#clear() {
|
|
90
|
+
if (this.#timer !== null) {
|
|
91
|
+
clearInterval(this.#timer);
|
|
92
|
+
this.#timer = null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=synthetic-source.js.map
|