@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 oxbshw
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# @deepwatch/dsh-live
|
|
2
|
+
|
|
3
|
+
Live mode — cursors, gaps, clocks, reconnect and a bounded buffer
|
|
4
|
+
|
|
5
|
+
Part of **DeepWatch** — the agent workspace built on the official
|
|
6
|
+
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
|
|
7
|
+
and powered by [Watch Skill](https://github.com/oxbshw/watch-skill) for perception, evidence, memory and
|
|
8
|
+
independent verification.
|
|
9
|
+
|
|
10
|
+
> **Host plugin — runs beside the agent in the DSH process.**
|
|
11
|
+
> Composed by the bundle. Needed for live and browser sessions.
|
|
12
|
+
|
|
13
|
+
## Exports
|
|
14
|
+
|
|
15
|
+
- `@deepwatch/dsh-live`
|
|
16
|
+
- `@deepwatch/dsh-live/capture`
|
|
17
|
+
- `@deepwatch/dsh-live/client`
|
|
18
|
+
- `@deepwatch/dsh-live/components`
|
|
19
|
+
- `@deepwatch/dsh-live/live-mode`
|
|
20
|
+
- `@deepwatch/dsh-live/sources-catalogue`
|
|
21
|
+
- `@deepwatch/dsh-live/synthetic-source`
|
|
22
|
+
|
|
23
|
+
## Peers
|
|
24
|
+
|
|
25
|
+
Provided by the host rather than installed here:
|
|
26
|
+
|
|
27
|
+
- `@deepseek-ai/cordis@4.0.2`
|
|
28
|
+
- `react@^18.2.0`
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
> **Not on npm yet.** Nothing exists under the `@deepwatch` scope. This
|
|
33
|
+
> package is published for the first time by the `deepwatch-v0.1.0`
|
|
34
|
+
> release; until then the command below resolves nothing, and
|
|
35
|
+
> [the workspace README](https://github.com/oxbshw/watch-skill/tree/main/workspace#readme) has the path
|
|
36
|
+
> that works from a checkout.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npm install @deepwatch/dsh-live
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Rarely on its own. [`@deepwatch/dsh-bundle`](https://github.com/oxbshw/watch-skill/tree/main/workspace/packages/watch/bundle#readme)
|
|
43
|
+
composes this package with the rest of DeepWatch and is what a profile
|
|
44
|
+
normally depends on; installing this one directly is for embedding a
|
|
45
|
+
single piece in a composition you control.
|
|
46
|
+
|
|
47
|
+
## Requirements
|
|
48
|
+
|
|
49
|
+
- Node `^22.19.0 || >=24.0.0`
|
|
50
|
+
- The peers above, supplied by the host composition
|
|
51
|
+
|
|
52
|
+
Core built with the `[loop]` extra, and a browser installed by `playwright install chromium`, for anything that drives a page.
|
|
53
|
+
|
|
54
|
+
## Stability
|
|
55
|
+
|
|
56
|
+
`0.1.0` — a stable release.
|
|
57
|
+
|
|
58
|
+
Stable means tested, documented and supported — not 1.0. This is a
|
|
59
|
+
pre-1.0 line, and semantic versioning gives `0.x` no compatibility
|
|
60
|
+
guarantee across minor versions: **a `0.MINOR` bump may change or remove
|
|
61
|
+
surface, and a patch will not.** Depend on it with a tilde range
|
|
62
|
+
(`~0.1.0`) if you want that difference enforced by your lockfile
|
|
63
|
+
rather than by a changelog. The usual major-version promise starts at 1.0.
|
|
64
|
+
|
|
65
|
+
## Side effects
|
|
66
|
+
|
|
67
|
+
Importing a module from this package evaluates no side effects, so a
|
|
68
|
+
bundler may drop what a build does not use. Mounting it in a host is a
|
|
69
|
+
separate matter: what it then reads or writes is governed by the
|
|
70
|
+
workspace boundary and the host's permissions, not by this flag.
|
|
71
|
+
|
|
72
|
+
## Where this fits
|
|
73
|
+
|
|
74
|
+
One clock across streams, with cursors, declared gaps, a bounded buffer and a reconnect policy — so "what was on screen when that was said" has an answer rather than a guess.
|
|
75
|
+
|
|
76
|
+
The twenty packages and how they compose:
|
|
77
|
+
[the package map](https://github.com/oxbshw/watch-skill/blob/main/workspace/docs/packages.md).
|
|
78
|
+
Running DeepWatch, and the gates a change has to pass:
|
|
79
|
+
[the workspace README](https://github.com/oxbshw/watch-skill/tree/main/workspace#readme).
|
|
80
|
+
|
|
81
|
+
## Attribution
|
|
82
|
+
|
|
83
|
+
Built on DeepSeek Harness · Powered by Watch Skill
|
|
84
|
+
|
|
85
|
+
DeepWatch and Watch Skill are independent projects and are not affiliated
|
|
86
|
+
with or endorsed by DeepSeek. MIT licensed; third-party notices are in
|
|
87
|
+
[THIRD_PARTY_NOTICES.md](https://github.com/oxbshw/watch-skill/blob/main/workspace/THIRD_PARTY_NOTICES.md).
|
package/lib/capture.d.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live capture lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* A session is a small state machine, and writing it as one is the point: every
|
|
5
|
+
* way a capture can end — stopped, cancelled, timed out, denied, failed, the
|
|
6
|
+
* source disappearing underneath it — is a named state with a named transition
|
|
7
|
+
* rather than an early return somewhere. The states that get skipped in ad-hoc
|
|
8
|
+
* implementations are exactly the ones that leak: a cancel during startup, a
|
|
9
|
+
* source vanishing mid-stream, a stop arriving twice.
|
|
10
|
+
*
|
|
11
|
+
* Three rules hold the design together.
|
|
12
|
+
*
|
|
13
|
+
* **Permission is never implied.** A session moves `idle → requested →
|
|
14
|
+
* granted` only through an explicit call that a user action caused. Nothing in
|
|
15
|
+
* construction, discovery or rendering asks for anything; a prompt on page load
|
|
16
|
+
* teaches people to allow without reading, and after that the prompt means
|
|
17
|
+
* nothing.
|
|
18
|
+
*
|
|
19
|
+
* **Observing is not operating.** The browser appears twice in the source
|
|
20
|
+
* catalogue because watching a page and acting on one carry different
|
|
21
|
+
* consequences. A single capability would grant the second while a person
|
|
22
|
+
* believed they were enabling the first.
|
|
23
|
+
*
|
|
24
|
+
* **Stopping always releases.** `stop`, `cancel`, `fail` and `timeout` all run
|
|
25
|
+
* the same teardown, exactly once, whatever order they arrive in. A capture
|
|
26
|
+
* that leaks a handle on the unhappy path leaks it on the path that actually
|
|
27
|
+
* happens.
|
|
28
|
+
*
|
|
29
|
+
* @module @deepwatch/dsh-live/capture
|
|
30
|
+
*/
|
|
31
|
+
/** Where a session is. Every terminal state is reachable and named. */
|
|
32
|
+
export type CaptureState = 'idle' | 'requesting_permission' | 'starting' | 'active' | 'paused' | 'stopping' | 'stopped' | 'cancelled' | 'denied' | 'unavailable' | 'timed_out' | 'failed';
|
|
33
|
+
/** Whether the person has agreed, and whether they were ever asked. */
|
|
34
|
+
export type PermissionState = 'not_requested' | 'requested' | 'granted' | 'denied';
|
|
35
|
+
/** One thing the source produced, with the clock that makes it citable. */
|
|
36
|
+
export interface Observation {
|
|
37
|
+
readonly observationId: string;
|
|
38
|
+
/** ISO-8601. An observation without a time cannot be cited. */
|
|
39
|
+
readonly at: string;
|
|
40
|
+
readonly kind: 'frame' | 'audio' | 'text' | 'event';
|
|
41
|
+
/** What was observed. Verbatim; never a summary. */
|
|
42
|
+
readonly text: string;
|
|
43
|
+
/** Milliseconds from the session's start, for a relative timeline. */
|
|
44
|
+
readonly offsetMs: number;
|
|
45
|
+
}
|
|
46
|
+
/** The receipt a finished session leaves behind. */
|
|
47
|
+
export interface CaptureReceipt {
|
|
48
|
+
readonly sessionId: string;
|
|
49
|
+
readonly sourceId: string;
|
|
50
|
+
readonly runId: string | null;
|
|
51
|
+
readonly startedAt: string | null;
|
|
52
|
+
readonly endedAt: string | null;
|
|
53
|
+
readonly finalState: CaptureState;
|
|
54
|
+
readonly observationCount: number;
|
|
55
|
+
/** Why it ended, in words. Empty for an ordinary stop. */
|
|
56
|
+
readonly reason: string;
|
|
57
|
+
/**
|
|
58
|
+
* Evidence minted from this session, if any.
|
|
59
|
+
*
|
|
60
|
+
* Always empty here. A capture produces observations; only Watch Core turns
|
|
61
|
+
* an observation into evidence, and this module cannot and must not.
|
|
62
|
+
*/
|
|
63
|
+
readonly evidenceIds: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
/** A source's own view of itself, before anything has been started. */
|
|
66
|
+
export interface SourceAvailability {
|
|
67
|
+
readonly available: boolean;
|
|
68
|
+
/** Why not, when not. Shown to a person, so it has to be actionable. */
|
|
69
|
+
readonly reason: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* What a real source has to provide.
|
|
73
|
+
*
|
|
74
|
+
* Deliberately tiny. An adapter that can be described in four methods can be
|
|
75
|
+
* tested deterministically with a fake, which is what lets the lifecycle above
|
|
76
|
+
* be exercised on a machine that has no camera.
|
|
77
|
+
*/
|
|
78
|
+
export interface CaptureAdapter {
|
|
79
|
+
readonly sourceId: string;
|
|
80
|
+
/** Can this run here, now? Must not prompt and must not open anything. */
|
|
81
|
+
probe(): Promise<SourceAvailability> | SourceAvailability;
|
|
82
|
+
/** Ask the person. Called only from an explicit user action. */
|
|
83
|
+
requestPermission(): Promise<boolean> | boolean;
|
|
84
|
+
/** Begin producing. `emit` may be called until `stop` resolves. */
|
|
85
|
+
start(emit: (observation: Observation) => void): Promise<void> | void;
|
|
86
|
+
/** Release everything. Must be safe to call more than once. */
|
|
87
|
+
stop(): Promise<void> | void;
|
|
88
|
+
}
|
|
89
|
+
export interface CaptureOptions {
|
|
90
|
+
readonly sessionId: string;
|
|
91
|
+
readonly runId?: string | null;
|
|
92
|
+
/** Give up if the source has not started within this long. */
|
|
93
|
+
readonly startTimeoutMs?: number;
|
|
94
|
+
/** Injected so tests are not slow and not flaky. */
|
|
95
|
+
readonly now?: () => Date;
|
|
96
|
+
}
|
|
97
|
+
/** One live capture, from nothing to a receipt. */
|
|
98
|
+
export declare class CaptureSession {
|
|
99
|
+
#private;
|
|
100
|
+
readonly sessionId: string;
|
|
101
|
+
readonly sourceId: string;
|
|
102
|
+
readonly runId: string | null;
|
|
103
|
+
constructor(adapter: CaptureAdapter, options: CaptureOptions);
|
|
104
|
+
get state(): CaptureState;
|
|
105
|
+
get permission(): PermissionState;
|
|
106
|
+
get observations(): readonly Observation[];
|
|
107
|
+
get startedAt(): string | null;
|
|
108
|
+
get reason(): string;
|
|
109
|
+
/** True once the session can no longer change state. */
|
|
110
|
+
get finished(): boolean;
|
|
111
|
+
subscribe(listener: (session: CaptureSession) => void): () => void;
|
|
112
|
+
/**
|
|
113
|
+
* Ask whether the source can run here.
|
|
114
|
+
*
|
|
115
|
+
* Explicitly not a permission request. Probing must be safe to do while
|
|
116
|
+
* rendering a list of sources, so an adapter that prompted here would make
|
|
117
|
+
* merely opening the tab ask for the camera.
|
|
118
|
+
*/
|
|
119
|
+
probe(): Promise<SourceAvailability>;
|
|
120
|
+
/**
|
|
121
|
+
* Ask the person for permission.
|
|
122
|
+
*
|
|
123
|
+
* Called only from an explicit user action — that is the contract, and the
|
|
124
|
+
* reason `start` refuses to do it implicitly.
|
|
125
|
+
*/
|
|
126
|
+
requestPermission(): Promise<boolean>;
|
|
127
|
+
/**
|
|
128
|
+
* Begin capturing.
|
|
129
|
+
*
|
|
130
|
+
* Refuses without permission rather than requesting it. A start that silently
|
|
131
|
+
* prompts is a start that can be triggered by something other than a person,
|
|
132
|
+
* and the whole point of the boundary is that it cannot.
|
|
133
|
+
*/
|
|
134
|
+
start(): Promise<boolean>;
|
|
135
|
+
/** Stop producing without ending the session. */
|
|
136
|
+
pause(): boolean;
|
|
137
|
+
resume(): boolean;
|
|
138
|
+
/** End normally. Safe to call twice; the second call is a no-op. */
|
|
139
|
+
stop(): Promise<CaptureReceipt>;
|
|
140
|
+
/**
|
|
141
|
+
* End before it began, or during startup.
|
|
142
|
+
*
|
|
143
|
+
* Distinct from `stop` because the states differ for a person: a cancelled
|
|
144
|
+
* capture produced nothing on purpose, a stopped one produced what it
|
|
145
|
+
* produced.
|
|
146
|
+
*/
|
|
147
|
+
cancel(reason?: string): Promise<CaptureReceipt>;
|
|
148
|
+
/** The source went away underneath us. */
|
|
149
|
+
sourceLost(reason: string): Promise<CaptureReceipt>;
|
|
150
|
+
/** What happened, in a form that outlives the session object. */
|
|
151
|
+
receipt(): CaptureReceipt;
|
|
152
|
+
}
|
|
153
|
+
/** Build an observation with a clock, so it can be cited. */
|
|
154
|
+
export declare function observationAt(base: Date, now: Date, kind: Observation['kind'], text: string, index: number): Observation;
|
|
155
|
+
//# sourceMappingURL=capture.d.ts.map
|
package/lib/capture.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live capture lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* A session is a small state machine, and writing it as one is the point: every
|
|
5
|
+
* way a capture can end — stopped, cancelled, timed out, denied, failed, the
|
|
6
|
+
* source disappearing underneath it — is a named state with a named transition
|
|
7
|
+
* rather than an early return somewhere. The states that get skipped in ad-hoc
|
|
8
|
+
* implementations are exactly the ones that leak: a cancel during startup, a
|
|
9
|
+
* source vanishing mid-stream, a stop arriving twice.
|
|
10
|
+
*
|
|
11
|
+
* Three rules hold the design together.
|
|
12
|
+
*
|
|
13
|
+
* **Permission is never implied.** A session moves `idle → requested →
|
|
14
|
+
* granted` only through an explicit call that a user action caused. Nothing in
|
|
15
|
+
* construction, discovery or rendering asks for anything; a prompt on page load
|
|
16
|
+
* teaches people to allow without reading, and after that the prompt means
|
|
17
|
+
* nothing.
|
|
18
|
+
*
|
|
19
|
+
* **Observing is not operating.** The browser appears twice in the source
|
|
20
|
+
* catalogue because watching a page and acting on one carry different
|
|
21
|
+
* consequences. A single capability would grant the second while a person
|
|
22
|
+
* believed they were enabling the first.
|
|
23
|
+
*
|
|
24
|
+
* **Stopping always releases.** `stop`, `cancel`, `fail` and `timeout` all run
|
|
25
|
+
* the same teardown, exactly once, whatever order they arrive in. A capture
|
|
26
|
+
* that leaks a handle on the unhappy path leaks it on the path that actually
|
|
27
|
+
* happens.
|
|
28
|
+
*
|
|
29
|
+
* @module @deepwatch/dsh-live/capture
|
|
30
|
+
*/
|
|
31
|
+
const DEFAULT_START_TIMEOUT = 10_000;
|
|
32
|
+
/** One live capture, from nothing to a receipt. */
|
|
33
|
+
export class CaptureSession {
|
|
34
|
+
sessionId;
|
|
35
|
+
sourceId;
|
|
36
|
+
runId;
|
|
37
|
+
#adapter;
|
|
38
|
+
#state = 'idle';
|
|
39
|
+
#permission = 'not_requested';
|
|
40
|
+
#observations = [];
|
|
41
|
+
#startedAt = null;
|
|
42
|
+
#endedAt = null;
|
|
43
|
+
#reason = '';
|
|
44
|
+
#now;
|
|
45
|
+
#startTimeout;
|
|
46
|
+
#torndown = false;
|
|
47
|
+
#timer = null;
|
|
48
|
+
#listeners = new Set();
|
|
49
|
+
constructor(adapter, options) {
|
|
50
|
+
this.#adapter = adapter;
|
|
51
|
+
this.sessionId = options.sessionId;
|
|
52
|
+
this.sourceId = adapter.sourceId;
|
|
53
|
+
this.runId = options.runId ?? null;
|
|
54
|
+
this.#now = options.now ?? (() => new Date());
|
|
55
|
+
this.#startTimeout = options.startTimeoutMs ?? DEFAULT_START_TIMEOUT;
|
|
56
|
+
}
|
|
57
|
+
get state() { return this.#state; }
|
|
58
|
+
get permission() { return this.#permission; }
|
|
59
|
+
get observations() { return this.#observations; }
|
|
60
|
+
get startedAt() { return this.#startedAt?.toISOString() ?? null; }
|
|
61
|
+
get reason() { return this.#reason; }
|
|
62
|
+
/** True once the session can no longer change state. */
|
|
63
|
+
get finished() {
|
|
64
|
+
return ['stopped', 'cancelled', 'denied', 'unavailable', 'timed_out', 'failed'].includes(this.#state);
|
|
65
|
+
}
|
|
66
|
+
subscribe(listener) {
|
|
67
|
+
this.#listeners.add(listener);
|
|
68
|
+
return () => { this.#listeners.delete(listener); };
|
|
69
|
+
}
|
|
70
|
+
#announce() {
|
|
71
|
+
for (const listener of this.#listeners)
|
|
72
|
+
listener(this);
|
|
73
|
+
}
|
|
74
|
+
#settle(state, reason) {
|
|
75
|
+
if (this.finished)
|
|
76
|
+
return;
|
|
77
|
+
this.#state = state;
|
|
78
|
+
this.#reason = reason;
|
|
79
|
+
this.#endedAt = this.#now();
|
|
80
|
+
this.#announce();
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Ask whether the source can run here.
|
|
84
|
+
*
|
|
85
|
+
* Explicitly not a permission request. Probing must be safe to do while
|
|
86
|
+
* rendering a list of sources, so an adapter that prompted here would make
|
|
87
|
+
* merely opening the tab ask for the camera.
|
|
88
|
+
*/
|
|
89
|
+
async probe() {
|
|
90
|
+
const availability = await this.#adapter.probe();
|
|
91
|
+
if (!availability.available && this.#state === 'idle') {
|
|
92
|
+
this.#state = 'unavailable';
|
|
93
|
+
this.#reason = availability.reason;
|
|
94
|
+
this.#announce();
|
|
95
|
+
}
|
|
96
|
+
return availability;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Ask the person for permission.
|
|
100
|
+
*
|
|
101
|
+
* Called only from an explicit user action — that is the contract, and the
|
|
102
|
+
* reason `start` refuses to do it implicitly.
|
|
103
|
+
*/
|
|
104
|
+
async requestPermission() {
|
|
105
|
+
if (this.finished)
|
|
106
|
+
return this.#permission === 'granted';
|
|
107
|
+
this.#state = 'requesting_permission';
|
|
108
|
+
this.#permission = 'requested';
|
|
109
|
+
this.#announce();
|
|
110
|
+
let granted = false;
|
|
111
|
+
try {
|
|
112
|
+
granted = await this.#adapter.requestPermission();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
this.#permission = 'denied';
|
|
116
|
+
this.#settle('failed', `The permission request failed: ${String(error)}`);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
if (!granted) {
|
|
120
|
+
this.#permission = 'denied';
|
|
121
|
+
this.#settle('denied', 'Permission was refused. Nothing was captured.');
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
this.#permission = 'granted';
|
|
125
|
+
this.#state = 'idle';
|
|
126
|
+
this.#announce();
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Begin capturing.
|
|
131
|
+
*
|
|
132
|
+
* Refuses without permission rather than requesting it. A start that silently
|
|
133
|
+
* prompts is a start that can be triggered by something other than a person,
|
|
134
|
+
* and the whole point of the boundary is that it cannot.
|
|
135
|
+
*/
|
|
136
|
+
async start() {
|
|
137
|
+
if (this.finished)
|
|
138
|
+
return false;
|
|
139
|
+
if (this.#permission !== 'granted') {
|
|
140
|
+
this.#settle('denied', 'Start needs permission, and permission is only ever asked for by an explicit action.');
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
const availability = await this.#adapter.probe();
|
|
144
|
+
if (!availability.available) {
|
|
145
|
+
this.#settle('unavailable', availability.reason);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
this.#state = 'starting';
|
|
149
|
+
this.#startedAt = this.#now();
|
|
150
|
+
this.#announce();
|
|
151
|
+
// A source that never starts must not hold the session open forever.
|
|
152
|
+
const timedOut = new Promise(resolve => {
|
|
153
|
+
this.#timer = setTimeout(() => { resolve('timeout'); }, this.#startTimeout);
|
|
154
|
+
});
|
|
155
|
+
try {
|
|
156
|
+
const outcome = await Promise.race([
|
|
157
|
+
Promise.resolve(this.#adapter.start(observation => { this.#record(observation); })).then(() => 'started'),
|
|
158
|
+
timedOut,
|
|
159
|
+
]);
|
|
160
|
+
this.#clearTimer();
|
|
161
|
+
// A cancel that arrived while we were starting wins. Without this check
|
|
162
|
+
// the session would come back to life after the user stopped it.
|
|
163
|
+
//
|
|
164
|
+
// The adapter is stopped directly rather than through `#teardown`, and
|
|
165
|
+
// that distinction is the whole fix for a real leak: the cancel already
|
|
166
|
+
// ran teardown, so the once-guard is set — but it ran *before* the
|
|
167
|
+
// adapter allocated anything, so it stopped nothing. Whatever `start`
|
|
168
|
+
// just created would have been left running forever. The adapter
|
|
169
|
+
// contract requires `stop` to be safe more than once precisely so this
|
|
170
|
+
// case can be handled without tracking who allocated what.
|
|
171
|
+
if (this.finished) {
|
|
172
|
+
try {
|
|
173
|
+
await this.#adapter.stop();
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// Nothing left to do: the session has already ended.
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (outcome === 'timeout') {
|
|
181
|
+
await this.#teardown();
|
|
182
|
+
this.#settle('timed_out', `The source did not start within ${String(this.#startTimeout)}ms.`);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
this.#clearTimer();
|
|
188
|
+
await this.#teardown();
|
|
189
|
+
this.#settle('failed', `The source failed to start: ${String(error)}`);
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
this.#state = 'active';
|
|
193
|
+
this.#announce();
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
/** Record an observation. Ignored unless the session is actually running. */
|
|
197
|
+
#record(observation) {
|
|
198
|
+
if (this.#state !== 'active')
|
|
199
|
+
return;
|
|
200
|
+
this.#observations.push(observation);
|
|
201
|
+
this.#announce();
|
|
202
|
+
}
|
|
203
|
+
/** Stop producing without ending the session. */
|
|
204
|
+
pause() {
|
|
205
|
+
if (this.#state !== 'active')
|
|
206
|
+
return false;
|
|
207
|
+
this.#state = 'paused';
|
|
208
|
+
this.#announce();
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
resume() {
|
|
212
|
+
if (this.#state !== 'paused')
|
|
213
|
+
return false;
|
|
214
|
+
this.#state = 'active';
|
|
215
|
+
this.#announce();
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
/** End normally. Safe to call twice; the second call is a no-op. */
|
|
219
|
+
async stop() {
|
|
220
|
+
if (!this.finished) {
|
|
221
|
+
this.#state = 'stopping';
|
|
222
|
+
this.#announce();
|
|
223
|
+
await this.#teardown();
|
|
224
|
+
this.#settle('stopped', '');
|
|
225
|
+
}
|
|
226
|
+
return this.receipt();
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* End before it began, or during startup.
|
|
230
|
+
*
|
|
231
|
+
* Distinct from `stop` because the states differ for a person: a cancelled
|
|
232
|
+
* capture produced nothing on purpose, a stopped one produced what it
|
|
233
|
+
* produced.
|
|
234
|
+
*/
|
|
235
|
+
async cancel(reason = 'Cancelled.') {
|
|
236
|
+
if (!this.finished) {
|
|
237
|
+
await this.#teardown();
|
|
238
|
+
this.#settle('cancelled', reason);
|
|
239
|
+
}
|
|
240
|
+
return this.receipt();
|
|
241
|
+
}
|
|
242
|
+
/** The source went away underneath us. */
|
|
243
|
+
async sourceLost(reason) {
|
|
244
|
+
if (!this.finished) {
|
|
245
|
+
await this.#teardown();
|
|
246
|
+
this.#settle('unavailable', reason);
|
|
247
|
+
}
|
|
248
|
+
return this.receipt();
|
|
249
|
+
}
|
|
250
|
+
#clearTimer() {
|
|
251
|
+
if (this.#timer !== null) {
|
|
252
|
+
clearTimeout(this.#timer);
|
|
253
|
+
this.#timer = null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Release everything, exactly once.
|
|
258
|
+
*
|
|
259
|
+
* Every ending routes through here, and the guard is what makes that safe: a
|
|
260
|
+
* stop racing a timeout would otherwise call the adapter's `stop` twice, and
|
|
261
|
+
* an adapter is entitled to assume it is torn down once.
|
|
262
|
+
*/
|
|
263
|
+
async #teardown() {
|
|
264
|
+
this.#clearTimer();
|
|
265
|
+
if (this.#torndown)
|
|
266
|
+
return;
|
|
267
|
+
this.#torndown = true;
|
|
268
|
+
try {
|
|
269
|
+
await this.#adapter.stop();
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// A teardown that throws must not prevent the session from ending; the
|
|
273
|
+
// alternative is a session stuck in `stopping` forever.
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** What happened, in a form that outlives the session object. */
|
|
277
|
+
receipt() {
|
|
278
|
+
return {
|
|
279
|
+
sessionId: this.sessionId,
|
|
280
|
+
sourceId: this.sourceId,
|
|
281
|
+
runId: this.runId,
|
|
282
|
+
startedAt: this.#startedAt?.toISOString() ?? null,
|
|
283
|
+
endedAt: this.#endedAt?.toISOString() ?? null,
|
|
284
|
+
finalState: this.#state,
|
|
285
|
+
observationCount: this.#observations.length,
|
|
286
|
+
reason: this.#reason,
|
|
287
|
+
evidenceIds: [],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** Build an observation with a clock, so it can be cited. */
|
|
292
|
+
export function observationAt(base, now, kind, text, index) {
|
|
293
|
+
return {
|
|
294
|
+
observationId: `obs-${String(index)}`,
|
|
295
|
+
at: now.toISOString(),
|
|
296
|
+
kind,
|
|
297
|
+
text,
|
|
298
|
+
offsetMs: Math.max(0, now.getTime() - base.getTime()),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
//# sourceMappingURL=capture.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Live surface.
|
|
3
|
+
*
|
|
4
|
+
* The header is deliberately busy. A live view's job is to be trustworthy
|
|
5
|
+
* about time, and that means the clocks, the latency, the connection state and
|
|
6
|
+
* the continuity line are all present at once rather than behind a disclosure.
|
|
7
|
+
* The moment one of them is hidden, the surface starts implying a continuity
|
|
8
|
+
* it has not checked.
|
|
9
|
+
*
|
|
10
|
+
* @module @deepwatch/dsh-live/components
|
|
11
|
+
*/
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
import { type LiveEvent, type LiveSessionState } from '../session.js';
|
|
14
|
+
/** Props for {@link LiveHeader}. */
|
|
15
|
+
export interface LiveHeaderProps {
|
|
16
|
+
readonly state: LiveSessionState;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Session, media and wall clocks, side by side.
|
|
20
|
+
*
|
|
21
|
+
* Three readings rather than one, because they answer three questions and
|
|
22
|
+
* disagree constantly during a live observation. A single "time" field would
|
|
23
|
+
* be right about one of them and quietly wrong about the other two.
|
|
24
|
+
*/
|
|
25
|
+
export declare function LiveHeader({ state }: LiveHeaderProps): ReactNode;
|
|
26
|
+
/** Props for {@link LiveEventRow}. */
|
|
27
|
+
export interface LiveEventRowProps {
|
|
28
|
+
readonly event: LiveEvent;
|
|
29
|
+
readonly onSelect: (event: LiveEvent) => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One observed event.
|
|
33
|
+
*
|
|
34
|
+
* A gap draws as a gap — dashed, glyphed and labelled — and is never
|
|
35
|
+
* collapsed into the events around it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function LiveEventRow({ event, onSelect }: LiveEventRowProps): ReactNode;
|
|
38
|
+
/** Props for {@link LiveSurface}. */
|
|
39
|
+
export interface LiveSurfaceProps {
|
|
40
|
+
readonly state: LiveSessionState;
|
|
41
|
+
readonly onStart: () => void;
|
|
42
|
+
readonly onStop: (finalize: boolean) => void;
|
|
43
|
+
readonly onAsk: (question: string) => void;
|
|
44
|
+
readonly onSelect: (event: LiveEvent) => void;
|
|
45
|
+
readonly onPin: () => void;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The Live mode body.
|
|
49
|
+
*
|
|
50
|
+
* `Resnapshot needed` is rendered as a banner rather than a toast. A transient
|
|
51
|
+
* notification for "your view of this is not continuous" is a notification
|
|
52
|
+
* that will be missed exactly when it matters.
|
|
53
|
+
*/
|
|
54
|
+
export declare function LiveSurface(props: LiveSurfaceProps): ReactNode;
|
|
55
|
+
//# sourceMappingURL=components.d.ts.map
|