@dg-kit/safety 1.14.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/README.md +16 -0
- package/dist/contracts.d.ts +44 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +9 -0
- package/dist/contracts.js.map +1 -0
- package/dist/default-policies.d.ts +36 -0
- package/dist/default-policies.d.ts.map +1 -0
- package/dist/default-policies.js +333 -0
- package/dist/default-policies.js.map +1 -0
- package/dist/device-command-queue.d.ts +60 -0
- package/dist/device-command-queue.d.ts.map +1 -0
- package/dist/device-command-queue.js +105 -0
- package/dist/device-command-queue.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle-guard.d.ts +30 -0
- package/dist/lifecycle-guard.d.ts.map +1 -0
- package/dist/lifecycle-guard.js +59 -0
- package/dist/lifecycle-guard.js.map +1 -0
- package/dist/policy-engine.d.ts +45 -0
- package/dist/policy-engine.d.ts.map +1 -0
- package/dist/policy-engine.js +31 -0
- package/dist/policy-engine.js.map +1 -0
- package/dist/safety-acceptance.d.ts +27 -0
- package/dist/safety-acceptance.d.ts.map +1 -0
- package/dist/safety-acceptance.js +64 -0
- package/dist/safety-acceptance.js.map +1 -0
- package/dist/safety-bus.d.ts +156 -0
- package/dist/safety-bus.d.ts.map +1 -0
- package/dist/safety-bus.js +169 -0
- package/dist/safety-bus.js.map +1 -0
- package/dist/safety-notice-content.d.ts +37 -0
- package/dist/safety-notice-content.d.ts.map +1 -0
- package/dist/safety-notice-content.js +72 -0
- package/dist/safety-notice-content.js.map +1 -0
- package/package.json +38 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './contracts.js';
|
|
2
|
+
export * from './policy-engine.js';
|
|
3
|
+
export * from './default-policies.js';
|
|
4
|
+
export * from './device-command-queue.js';
|
|
5
|
+
export * from './safety-bus.js';
|
|
6
|
+
export * from './lifecycle-guard.js';
|
|
7
|
+
export * from './safety-notice-content.js';
|
|
8
|
+
export * from './safety-acceptance.js';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wBAAwB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// @dg-kit/safety — the single source of truth for the device safety chain.
|
|
2
|
+
//
|
|
3
|
+
// Policy engine (strength caps / cold-start clamp / burst-specific caps /
|
|
4
|
+
// cumulative ceiling), default policies, and the serial command queue (with
|
|
5
|
+
// emergency-stop preemption and generation invalidation). Before the merge
|
|
6
|
+
// DG-Agent and DG-Voice each held a copy; stripped of imports and comments
|
|
7
|
+
// the two were byte-identical, so merging involved no behavioral judgment
|
|
8
|
+
// calls.
|
|
9
|
+
export * from './contracts.js';
|
|
10
|
+
export * from './policy-engine.js';
|
|
11
|
+
export * from './default-policies.js';
|
|
12
|
+
export * from './device-command-queue.js';
|
|
13
|
+
export * from './safety-bus.js';
|
|
14
|
+
export * from './lifecycle-guard.js';
|
|
15
|
+
export * from './safety-notice-content.js';
|
|
16
|
+
export * from './safety-acceptance.js';
|
|
17
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,EAAE;AACF,0EAA0E;AAC1E,4EAA4E;AAC5E,2EAA2E;AAC3E,2EAA2E;AAC3E,0EAA0E;AAC1E,SAAS;AACT,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wBAAwB,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stops device output when the page goes away or into the background.
|
|
3
|
+
*
|
|
4
|
+
* There used to be two of these — DG-Agent's `BrowserSafetyGuard` and
|
|
5
|
+
* DG-Voice's `CallSafetyGuard`, near-identical down to the in-flight guard
|
|
6
|
+
* and the listener set. They differed in exactly one way: Agent made
|
|
7
|
+
* "keep running while backgrounded" a user setting, Voice always stopped.
|
|
8
|
+
*
|
|
9
|
+
* This keeps Voice's behavior, for everyone. Screen off or tab away means
|
|
10
|
+
* stop, unconditionally, and there is no option to turn that off — which is
|
|
11
|
+
* also why the two can now be one class without the earlier worry that a
|
|
12
|
+
* wrong default would quietly ship "keeps firing while backgrounded" to a
|
|
13
|
+
* module whose author never chose it.
|
|
14
|
+
*
|
|
15
|
+
* Android already worked this way regardless of the setting (see the shell's
|
|
16
|
+
* lifecycle safety), so the setting was already a lie on the platform where
|
|
17
|
+
* it mattered most.
|
|
18
|
+
*/
|
|
19
|
+
export type LifecycleStopReason = 'leave-page' | 'background-hidden';
|
|
20
|
+
export interface DeviceLifecycleGuardOptions {
|
|
21
|
+
/** Called to stop output. Must be idempotent — the guard may fire more than once. */
|
|
22
|
+
onStop: (reason: LifecycleStopReason) => void | Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export declare class DeviceLifecycleGuard {
|
|
25
|
+
private readonly onStop;
|
|
26
|
+
constructor(options: DeviceLifecycleGuardOptions);
|
|
27
|
+
/** Begin watching. Returns the unsubscribe. */
|
|
28
|
+
start(): () => void;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=lifecycle-guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lifecycle-guard.d.ts","sourceRoot":"","sources":["../src/lifecycle-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,MAAM,mBAAmB,GAAG,YAAY,GAAG,mBAAmB,CAAC;AAErE,MAAM,WAAW,2BAA2B;IAC1C,qFAAqF;IACrF,MAAM,EAAE,CAAC,MAAM,EAAE,mBAAmB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/D;AAED,qBAAa,oBAAoB;IAI/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAwC;gBAEnD,OAAO,EAAE,2BAA2B;IAIhD,+CAA+C;IAC/C,KAAK,IAAI,MAAM,IAAI;CAgCpB"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stops device output when the page goes away or into the background.
|
|
3
|
+
*
|
|
4
|
+
* There used to be two of these — DG-Agent's `BrowserSafetyGuard` and
|
|
5
|
+
* DG-Voice's `CallSafetyGuard`, near-identical down to the in-flight guard
|
|
6
|
+
* and the listener set. They differed in exactly one way: Agent made
|
|
7
|
+
* "keep running while backgrounded" a user setting, Voice always stopped.
|
|
8
|
+
*
|
|
9
|
+
* This keeps Voice's behavior, for everyone. Screen off or tab away means
|
|
10
|
+
* stop, unconditionally, and there is no option to turn that off — which is
|
|
11
|
+
* also why the two can now be one class without the earlier worry that a
|
|
12
|
+
* wrong default would quietly ship "keeps firing while backgrounded" to a
|
|
13
|
+
* module whose author never chose it.
|
|
14
|
+
*
|
|
15
|
+
* Android already worked this way regardless of the setting (see the shell's
|
|
16
|
+
* lifecycle safety), so the setting was already a lie on the platform where
|
|
17
|
+
* it mattered most.
|
|
18
|
+
*/
|
|
19
|
+
export class DeviceLifecycleGuard {
|
|
20
|
+
// Not a constructor parameter property: `erasableSyntaxOnly` is on in Chat
|
|
21
|
+
// and the Android shell, and that syntax fails their build with TS1294
|
|
22
|
+
// while typecheck stays green.
|
|
23
|
+
onStop;
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.onStop = options.onStop;
|
|
26
|
+
}
|
|
27
|
+
/** Begin watching. Returns the unsubscribe. */
|
|
28
|
+
start() {
|
|
29
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
30
|
+
return () => undefined;
|
|
31
|
+
}
|
|
32
|
+
// One stop at a time: pagehide and visibilitychange both fire when a tab
|
|
33
|
+
// is closed, and a second stop landing mid-flight would race the first.
|
|
34
|
+
let inFlight = null;
|
|
35
|
+
const stop = (reason) => {
|
|
36
|
+
if (inFlight)
|
|
37
|
+
return;
|
|
38
|
+
inFlight = Promise.resolve(this.onStop(reason)).finally(() => {
|
|
39
|
+
inFlight = null;
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
const onLeave = () => stop('leave-page');
|
|
43
|
+
const onVisibility = () => {
|
|
44
|
+
if (document.visibilityState === 'hidden')
|
|
45
|
+
stop('background-hidden');
|
|
46
|
+
};
|
|
47
|
+
// Both leave events: pagehide is the reliable one on mobile Safari, and
|
|
48
|
+
// beforeunload is what fires on desktop closes.
|
|
49
|
+
window.addEventListener('pagehide', onLeave);
|
|
50
|
+
window.addEventListener('beforeunload', onLeave);
|
|
51
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
52
|
+
return () => {
|
|
53
|
+
window.removeEventListener('pagehide', onLeave);
|
|
54
|
+
window.removeEventListener('beforeunload', onLeave);
|
|
55
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=lifecycle-guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lifecycle-guard.js","sourceRoot":"","sources":["../src/lifecycle-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AASH,MAAM,OAAO,oBAAoB;IAC/B,2EAA2E;IAC3E,uEAAuE;IACvE,+BAA+B;IACd,MAAM,CAAwC;IAE/D,YAAY,OAAoC;QAC9C,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAC/B,CAAC;IAED,+CAA+C;IAC/C,KAAK;QACH,IAAI,OAAO,MAAM,KAAK,WAAW,IAAI,OAAO,QAAQ,KAAK,WAAW,EAAE,CAAC;YACrE,OAAO,GAAG,EAAE,CAAC,SAAS,CAAC;QACzB,CAAC;QAED,yEAAyE;QACzE,wEAAwE;QACxE,IAAI,QAAQ,GAAyB,IAAI,CAAC;QAC1C,MAAM,IAAI,GAAG,CAAC,MAA2B,EAAE,EAAE;YAC3C,IAAI,QAAQ;gBAAE,OAAO;YACrB,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE;gBAC3D,QAAQ,GAAG,IAAI,CAAC;YAClB,CAAC,CAAC,CAAC;QACL,CAAC,CAAC;QAEF,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QACzC,MAAM,YAAY,GAAG,GAAG,EAAE;YACxB,IAAI,QAAQ,CAAC,eAAe,KAAK,QAAQ;gBAAE,IAAI,CAAC,mBAAmB,CAAC,CAAC;QACvE,CAAC,CAAC;QAEF,wEAAwE;QACxE,gDAAgD;QAChD,MAAM,CAAC,gBAAgB,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;QAC7C,MAAM,CAAC,gBAAgB,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;QACjD,QAAQ,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,YAAY,CAAC,CAAC;QAE5D,OAAO,GAAG,EAAE;YACV,MAAM,CAAC,mBAAmB,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;YAChD,MAAM,CAAC,mBAAmB,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;YACpD,QAAQ,CAAC,mBAAmB,CAAC,kBAAkB,EAAE,YAAY,CAAC,CAAC;QACjE,CAAC,CAAC;IACJ,CAAC;CACF"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ActionContext, PolicyDecision } from './contracts.js';
|
|
2
|
+
import type { DeviceCommand, DeviceState, OpossumCommand } from '@dg-kit/core';
|
|
3
|
+
import type { OpossumState } from '@dg-kit/protocol';
|
|
4
|
+
export interface EvaluatePolicyInput {
|
|
5
|
+
context: ActionContext;
|
|
6
|
+
command: DeviceCommand;
|
|
7
|
+
deviceState: DeviceState;
|
|
8
|
+
}
|
|
9
|
+
export interface PolicyRule {
|
|
10
|
+
name: string;
|
|
11
|
+
evaluate(input: EvaluatePolicyInput): PolicyDecision | null;
|
|
12
|
+
}
|
|
13
|
+
export declare class PolicyEngine {
|
|
14
|
+
private readonly rules;
|
|
15
|
+
constructor(rules: PolicyRule[]);
|
|
16
|
+
evaluate(input: EvaluatePolicyInput): PolicyDecision;
|
|
17
|
+
}
|
|
18
|
+
export type OpossumPolicyDecision = {
|
|
19
|
+
type: 'allow';
|
|
20
|
+
} | {
|
|
21
|
+
type: 'deny';
|
|
22
|
+
reason: string;
|
|
23
|
+
} | {
|
|
24
|
+
type: 'clamp';
|
|
25
|
+
command: OpossumCommand;
|
|
26
|
+
reason: string;
|
|
27
|
+
} | {
|
|
28
|
+
type: 'require-confirm';
|
|
29
|
+
reason: string;
|
|
30
|
+
};
|
|
31
|
+
export interface EvaluateOpossumPolicyInput {
|
|
32
|
+
context: ActionContext;
|
|
33
|
+
command: OpossumCommand;
|
|
34
|
+
deviceState: OpossumState;
|
|
35
|
+
}
|
|
36
|
+
export interface OpossumPolicyRule {
|
|
37
|
+
name: string;
|
|
38
|
+
evaluate(input: EvaluateOpossumPolicyInput): OpossumPolicyDecision | null;
|
|
39
|
+
}
|
|
40
|
+
export declare class OpossumPolicyEngine {
|
|
41
|
+
private readonly rules;
|
|
42
|
+
constructor(rules: OpossumPolicyRule[]);
|
|
43
|
+
evaluate(input: EvaluateOpossumPolicyInput): OpossumPolicyDecision;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=policy-engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy-engine.d.ts","sourceRoot":"","sources":["../src/policy-engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACpE,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC/E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAErD,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,aAAa,CAAC;IACvB,OAAO,EAAE,aAAa,CAAC;IACvB,WAAW,EAAE,WAAW,CAAC;CAC1B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,KAAK,EAAE,mBAAmB,GAAG,cAAc,GAAG,IAAI,CAAC;CAC7D;AAED,qBAAa,YAAY;IACX,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,UAAU,EAAE;IAEhD,QAAQ,CAAC,KAAK,EAAE,mBAAmB,GAAG,cAAc;CAUrD;AAcD,MAAM,MAAM,qBAAqB,GAC7B;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,GACjB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,cAAc,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEhD,MAAM,WAAW,0BAA0B;IACzC,OAAO,EAAE,aAAa,CAAC;IACvB,OAAO,EAAE,cAAc,CAAC;IACxB,WAAW,EAAE,YAAY,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,KAAK,EAAE,0BAA0B,GAAG,qBAAqB,GAAG,IAAI,CAAC;CAC3E;AAED,qBAAa,mBAAmB;IAClB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,iBAAiB,EAAE;IAEvD,QAAQ,CAAC,KAAK,EAAE,0BAA0B,GAAG,qBAAqB;CAUnE"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export class PolicyEngine {
|
|
2
|
+
rules;
|
|
3
|
+
constructor(rules) {
|
|
4
|
+
this.rules = rules;
|
|
5
|
+
}
|
|
6
|
+
evaluate(input) {
|
|
7
|
+
for (const rule of this.rules) {
|
|
8
|
+
const result = rule.evaluate(input);
|
|
9
|
+
if (result) {
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return { type: 'allow' };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class OpossumPolicyEngine {
|
|
17
|
+
rules;
|
|
18
|
+
constructor(rules) {
|
|
19
|
+
this.rules = rules;
|
|
20
|
+
}
|
|
21
|
+
evaluate(input) {
|
|
22
|
+
for (const rule of this.rules) {
|
|
23
|
+
const result = rule.evaluate(input);
|
|
24
|
+
if (result) {
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { type: 'allow' };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=policy-engine.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy-engine.js","sourceRoot":"","sources":["../src/policy-engine.ts"],"names":[],"mappings":"AAeA,MAAM,OAAO,YAAY;IACM;IAA7B,YAA6B,KAAmB;QAAnB,UAAK,GAAL,KAAK,CAAc;IAAG,CAAC;IAEpD,QAAQ,CAAC,KAA0B;QACjC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YAC9B,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;YACpC,IAAI,MAAM,EAAE,CAAC;gBACX,OAAO,MAAM,CAAC;YAChB,CAAC;QACH,CAAC;QAED,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;IAC3B,CAAC;CACF;AA+BD,MAAM,OAAO,mBAAmB;IACD;IAA7B,YAA6B,KAA0B;QAA1B,UAAK,GAAL,KAAK,CAAqB;IAAG,CAAC;IAE3D,QAAQ,CAAC,KAAiC;QACxC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YAC9B,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;YACpC,IAAI,MAAM,EAAE,CAAC;gBACX,OAAO,MAAM,CAAC;YAChB,CAAC;QACH,CAAC;QAED,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;IAC3B,CAAC;CACF"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acceptance state of the safety notice.
|
|
3
|
+
*
|
|
4
|
+
* One copy for the whole system. Before the merge, Agent and Chat each gated
|
|
5
|
+
* their own entry point and tracked their own state, so inside the unified
|
|
6
|
+
* shell the same notice had to be accepted twice. The notice itself now
|
|
7
|
+
* exists once (see `safety-notice-content.ts`), so its acceptance state must
|
|
8
|
+
* too — confirmed once on entering the app, counted for all modules.
|
|
9
|
+
*
|
|
10
|
+
* The gate itself is not weakened: the default is "show", and only an
|
|
11
|
+
* explicit "do not show again" checkbox is remembered. Without the checkbox
|
|
12
|
+
* the acceptance lasts for the session and the notice shows again next
|
|
13
|
+
* launch.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Whether the user has permanently accepted.
|
|
17
|
+
*
|
|
18
|
+
* Returns false when storage is unreadable — it MUST be false. In private
|
|
19
|
+
* browsing or on a storage error, showing the notice one extra time is fine;
|
|
20
|
+
* silently waving the user through because a read failed is not.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isSafetyNoticeAccepted(): boolean;
|
|
23
|
+
/** Persist "do not show again". Call only on an explicit user checkbox. */
|
|
24
|
+
export declare function rememberSafetyNoticeAccepted(): void;
|
|
25
|
+
/** Re-arm the notice (the "show safety notice again" setting). */
|
|
26
|
+
export declare function forgetSafetyNoticeAccepted(): void;
|
|
27
|
+
//# sourceMappingURL=safety-acceptance.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"safety-acceptance.d.ts","sourceRoot":"","sources":["../src/safety-acceptance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAMH;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI,OAAO,CAchD;AAED,2EAA2E;AAC3E,wBAAgB,4BAA4B,IAAI,IAAI,CAOnD;AAED,kEAAkE;AAClE,wBAAgB,0BAA0B,IAAI,IAAI,CAOjD"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acceptance state of the safety notice.
|
|
3
|
+
*
|
|
4
|
+
* One copy for the whole system. Before the merge, Agent and Chat each gated
|
|
5
|
+
* their own entry point and tracked their own state, so inside the unified
|
|
6
|
+
* shell the same notice had to be accepted twice. The notice itself now
|
|
7
|
+
* exists once (see `safety-notice-content.ts`), so its acceptance state must
|
|
8
|
+
* too — confirmed once on entering the app, counted for all modules.
|
|
9
|
+
*
|
|
10
|
+
* The gate itself is not weakened: the default is "show", and only an
|
|
11
|
+
* explicit "do not show again" checkbox is remembered. Without the checkbox
|
|
12
|
+
* the acceptance lasts for the session and the notice shows again next
|
|
13
|
+
* launch.
|
|
14
|
+
*/
|
|
15
|
+
const KEY = '0xnullai.safety-accepted';
|
|
16
|
+
/** Per-module keys from before the merge, migrated once on read. */
|
|
17
|
+
const LEGACY_KEYS = ['dg-chat-safety-accepted'];
|
|
18
|
+
/**
|
|
19
|
+
* Whether the user has permanently accepted.
|
|
20
|
+
*
|
|
21
|
+
* Returns false when storage is unreadable — it MUST be false. In private
|
|
22
|
+
* browsing or on a storage error, showing the notice one extra time is fine;
|
|
23
|
+
* silently waving the user through because a read failed is not.
|
|
24
|
+
*/
|
|
25
|
+
export function isSafetyNoticeAccepted() {
|
|
26
|
+
if (typeof localStorage === 'undefined')
|
|
27
|
+
return false;
|
|
28
|
+
try {
|
|
29
|
+
if (localStorage.getItem(KEY) === 'true')
|
|
30
|
+
return true;
|
|
31
|
+
for (const legacy of LEGACY_KEYS) {
|
|
32
|
+
if (localStorage.getItem(legacy) === 'true') {
|
|
33
|
+
localStorage.setItem(KEY, 'true');
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Unreadable storage counts as not accepted.
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
/** Persist "do not show again". Call only on an explicit user checkbox. */
|
|
44
|
+
export function rememberSafetyNoticeAccepted() {
|
|
45
|
+
try {
|
|
46
|
+
localStorage.setItem(KEY, 'true');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// If the write fails, the notice shows again next time — failing in
|
|
50
|
+
// this direction is safe.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Re-arm the notice (the "show safety notice again" setting). */
|
|
54
|
+
export function forgetSafetyNoticeAccepted() {
|
|
55
|
+
try {
|
|
56
|
+
localStorage.removeItem(KEY);
|
|
57
|
+
for (const legacy of LEGACY_KEYS)
|
|
58
|
+
localStorage.removeItem(legacy);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Ignore: if this fails the user can still clear site data.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=safety-acceptance.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"safety-acceptance.js","sourceRoot":"","sources":["../src/safety-acceptance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,MAAM,GAAG,GAAG,0BAA0B,CAAC;AACvC,oEAAoE;AACpE,MAAM,WAAW,GAAG,CAAC,yBAAyB,CAAC,CAAC;AAEhD;;;;;;GAMG;AACH,MAAM,UAAU,sBAAsB;IACpC,IAAI,OAAO,YAAY,KAAK,WAAW;QAAE,OAAO,KAAK,CAAC;IACtD,IAAI,CAAC;QACH,IAAI,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,MAAM;YAAE,OAAO,IAAI,CAAC;QACtD,KAAK,MAAM,MAAM,IAAI,WAAW,EAAE,CAAC;YACjC,IAAI,YAAY,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,MAAM,EAAE,CAAC;gBAC5C,YAAY,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;gBAClC,OAAO,IAAI,CAAC;YACd,CAAC;QACH,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,6CAA6C;IAC/C,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,4BAA4B;IAC1C,IAAI,CAAC;QACH,YAAY,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACpC,CAAC;IAAC,MAAM,CAAC;QACP,oEAAoE;QACpE,0BAA0B;IAC5B,CAAC;AACH,CAAC;AAED,kEAAkE;AAClE,MAAM,UAAU,0BAA0B;IACxC,IAAI,CAAC;QACH,YAAY,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;QAC7B,KAAK,MAAM,MAAM,IAAI,WAAW;YAAE,YAAY,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;IACpE,CAAC;IAAC,MAAM,CAAC;QACP,4DAA4D;IAC9D,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safety bus: keeps "stop" reachable regardless of UI state.
|
|
3
|
+
*
|
|
4
|
+
* Each module registers its own emergency-stop function here, so the shell
|
|
5
|
+
* (or any orchestration layer) can stop *every* registered device session in
|
|
6
|
+
* one place, without knowing which modules exist or how each implements its
|
|
7
|
+
* stop.
|
|
8
|
+
*
|
|
9
|
+
* Why it exists: a module that is switched away from gets hidden (it stays
|
|
10
|
+
* mounted so BLE doesn't drop), which means its own stop button is
|
|
11
|
+
* unreachable while its device is *still outputting*. The shell needs a stop
|
|
12
|
+
* anchor that doesn't come and go with module visibility.
|
|
13
|
+
*
|
|
14
|
+
* Three design constraints:
|
|
15
|
+
* 1. `stopAll` must be best-effort — it must NOT abort because one session
|
|
16
|
+
* throws. When one module's BLE is already gone and throws, the other
|
|
17
|
+
* modules' devices are still outputting.
|
|
18
|
+
* 2. The registry is a Map, not an array: on hot reload / remount a module
|
|
19
|
+
* overwrites its entry by id instead of piling up stale closures —
|
|
20
|
+
* otherwise stopAll would invoke functions of unmounted modules.
|
|
21
|
+
* 3. No "remember who was outputting last" optimization. Stop is a
|
|
22
|
+
* low-frequency, high-stakes operation; stopping a few idle devices is
|
|
23
|
+
* fine, missing one because of a state-tracking bug is not.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Summary of one connected device, for the shell's device bar.
|
|
27
|
+
*
|
|
28
|
+
* Lives on the safety bus rather than in a separate registry: the device bar
|
|
29
|
+
* and the stop button are two faces of the same thing — listing devices tells
|
|
30
|
+
* the user what is on their body, and stop must sit right next to that. Two
|
|
31
|
+
* registries could disagree, and the moment they disagree is exactly the
|
|
32
|
+
* dangerous one.
|
|
33
|
+
*/
|
|
34
|
+
export interface DeviceSummary {
|
|
35
|
+
/** Stable and unique within one module. */
|
|
36
|
+
id: string;
|
|
37
|
+
/** Device kind: coyote / opossum / paw-prints / civet-edging. */
|
|
38
|
+
kind: string;
|
|
39
|
+
/** User-facing name, usually the BLE advertised name. */
|
|
40
|
+
name: string;
|
|
41
|
+
connected: boolean;
|
|
42
|
+
/** Battery percentage; omitted when unknown. */
|
|
43
|
+
battery?: number;
|
|
44
|
+
/** Whether it is currently outputting. Visual emphasis only — NOT used to decide whether the stop button shows. */
|
|
45
|
+
active?: boolean;
|
|
46
|
+
/** Per-channel strength, for the live readout on the device bar. */
|
|
47
|
+
channels?: {
|
|
48
|
+
label: string;
|
|
49
|
+
value: number;
|
|
50
|
+
max: number;
|
|
51
|
+
}[];
|
|
52
|
+
}
|
|
53
|
+
export interface SafetySession {
|
|
54
|
+
/** Module id, used for overwrite-on-register and diagnostics. */
|
|
55
|
+
id: string;
|
|
56
|
+
/** User-facing name, appears in prompts like "outputting: Agent". */
|
|
57
|
+
label: string;
|
|
58
|
+
/**
|
|
59
|
+
* Whether this module currently *holds a connected device*. Note: not
|
|
60
|
+
* "whether it is outputting".
|
|
61
|
+
*
|
|
62
|
+
* Tying the stop anchor's visibility to "is outputting" makes it depend on
|
|
63
|
+
* a state chain that can break at any time (missed subscription update,
|
|
64
|
+
* lease just revoked while the device is still running). One wrong link and
|
|
65
|
+
* the button disappears at exactly the moment it is needed most. Better to
|
|
66
|
+
* long-show a button that may stop an idle device.
|
|
67
|
+
*/
|
|
68
|
+
isActive: () => boolean;
|
|
69
|
+
/** Zero all device output of this module. Must be idempotent — may be called repeatedly. */
|
|
70
|
+
stop: () => void | Promise<void>;
|
|
71
|
+
/** Devices this module currently holds. The shell renders the device bar from it. */
|
|
72
|
+
devices?: () => DeviceSummary[];
|
|
73
|
+
/**
|
|
74
|
+
* Open this module's unified device picker. When present, the shell renders
|
|
75
|
+
* the one connect entry in its top device strip; modules must not duplicate
|
|
76
|
+
* the same action inside their content while mounted in the shell.
|
|
77
|
+
*/
|
|
78
|
+
connect?: () => void | Promise<unknown>;
|
|
79
|
+
/** Disconnect one device listed by `devices()`. Used by the shell's top strip. */
|
|
80
|
+
disconnect?: (deviceId: string) => void | Promise<unknown>;
|
|
81
|
+
/**
|
|
82
|
+
* Called when the module loses the device lease.
|
|
83
|
+
*
|
|
84
|
+
* It MUST do three things: stop output, clear any "held down" aggregate
|
|
85
|
+
* state, and reject subsequent commands. Doing only the first is not
|
|
86
|
+
* enough — e.g. Chat's fire aggregation snapshots a baseline on the
|
|
87
|
+
* empty-to-nonempty edge; if revocation lands while someone is holding
|
|
88
|
+
* fire, the matching release message never arrives and strength stays at
|
|
89
|
+
* baseline+boost instead of falling back.
|
|
90
|
+
*
|
|
91
|
+
* It must NEVER be implemented as disconnect(). Agent and Voice clients
|
|
92
|
+
* run with autoReconnect; surrendering control by disconnecting lets the
|
|
93
|
+
* background module silently reconnect inside the GATT-disconnect event
|
|
94
|
+
* and steal the device back (the client still caches the BluetoothDevice
|
|
95
|
+
* reference, so reconnecting needs no user gesture) — while the new holder
|
|
96
|
+
* believes it has exclusive control.
|
|
97
|
+
*/
|
|
98
|
+
onRevoke?: () => void | Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
export interface StopAllResult {
|
|
101
|
+
/** Number of sessions a stop was attempted for. */
|
|
102
|
+
attempted: number;
|
|
103
|
+
/** Sessions that threw, with the error. The rest are stopped. */
|
|
104
|
+
failed: {
|
|
105
|
+
id: string;
|
|
106
|
+
error: unknown;
|
|
107
|
+
}[];
|
|
108
|
+
}
|
|
109
|
+
/** Register a module's device session. Returns an unregister function. */
|
|
110
|
+
export declare function registerSafetySession(session: SafetySession): () => void;
|
|
111
|
+
/** A registered module session, including its optional shell connect action. */
|
|
112
|
+
export declare function safetySessionById(id: string | null): SafetySession | null;
|
|
113
|
+
/** Modules that currently have an active device session. */
|
|
114
|
+
export declare function activeSafetySessions(): SafetySession[];
|
|
115
|
+
export declare function hasActiveSafetySession(): boolean;
|
|
116
|
+
/**
|
|
117
|
+
* All devices currently held, grouped by module.
|
|
118
|
+
*
|
|
119
|
+
* If a module's check throws, skip that module instead of aborting — one
|
|
120
|
+
* module's broken state read must not blank the whole device bar, which
|
|
121
|
+
* would also hide the stop button next to it.
|
|
122
|
+
*/
|
|
123
|
+
export declare function allConnectedDevices(): {
|
|
124
|
+
sessionId: string;
|
|
125
|
+
label: string;
|
|
126
|
+
devices: DeviceSummary[];
|
|
127
|
+
}[];
|
|
128
|
+
/**
|
|
129
|
+
* Stop every registered session.
|
|
130
|
+
*
|
|
131
|
+
* allSettled, not all: one module throwing must not abort the others' stops
|
|
132
|
+
* — that is exactly the dangerous case (one device throws on a dropped
|
|
133
|
+
* connection while another keeps outputting).
|
|
134
|
+
*/
|
|
135
|
+
export declare function stopAllSafetySessions(): Promise<StopAllResult>;
|
|
136
|
+
/**
|
|
137
|
+
* Hand the device lease to a module. All other modules lose control
|
|
138
|
+
* immediately and are told to stop.
|
|
139
|
+
*
|
|
140
|
+
* Revocation is best-effort: one module's onRevoke throwing must not abort
|
|
141
|
+
* the others' revocation — that is exactly the dangerous case (one module
|
|
142
|
+
* errors while another is still being remotely controlled).
|
|
143
|
+
*/
|
|
144
|
+
export declare function grantDeviceLease(moduleId: string | null): Promise<void>;
|
|
145
|
+
/**
|
|
146
|
+
* Whether this module currently holds the device lease.
|
|
147
|
+
*
|
|
148
|
+
* Modules check this before *every* device command, not just to disable
|
|
149
|
+
* buttons — remote commands (other room members, AI) never pass through the
|
|
150
|
+
* UI.
|
|
151
|
+
*/
|
|
152
|
+
export declare function hasDeviceLease(moduleId: string): boolean;
|
|
153
|
+
export declare function currentDeviceLease(): string | null;
|
|
154
|
+
/** Subscribe to registry changes (module mount/unmount, lease transfer). */
|
|
155
|
+
export declare function subscribeSafetySessions(listener: () => void): () => void;
|
|
156
|
+
//# sourceMappingURL=safety-bus.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"safety-bus.d.ts","sourceRoot":"","sources":["../src/safety-bus.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;;;GAQG;AACH,MAAM,WAAW,aAAa;IAC5B,2CAA2C;IAC3C,EAAE,EAAE,MAAM,CAAC;IACX,iEAAiE;IACjE,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC;IACnB,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,mHAAmH;IACnH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,oEAAoE;IACpE,QAAQ,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC5D;AAED,MAAM,WAAW,aAAa;IAC5B,iEAAiE;IACjE,EAAE,EAAE,MAAM,CAAC;IACX,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;OASG;IACH,QAAQ,EAAE,MAAM,OAAO,CAAC;IACxB,4FAA4F;IAC5F,IAAI,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjC,qFAAqF;IACrF,OAAO,CAAC,EAAE,MAAM,aAAa,EAAE,CAAC;IAChC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACxC,kFAAkF;IAClF,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3D;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,aAAa;IAC5B,mDAAmD;IACnD,SAAS,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,EAAE,CAAC;CAC1C;AAmBD,0EAA0E;AAC1E,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,IAAI,CAWxE;AAED,gFAAgF;AAChF,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,aAAa,GAAG,IAAI,CAEzE;AAED,4DAA4D;AAC5D,wBAAgB,oBAAoB,IAAI,aAAa,EAAE,CAUtD;AAED,wBAAgB,sBAAsB,IAAI,OAAO,CAEhD;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,IAAI;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,aAAa,EAAE,CAAC;CAC1B,EAAE,CAWF;AAED;;;;;;GAMG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,aAAa,CAAC,CAcpE;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAyB7E;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,wBAAgB,kBAAkB,IAAI,MAAM,GAAG,IAAI,CAElD;AAED,4EAA4E;AAC5E,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAGxE"}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safety bus: keeps "stop" reachable regardless of UI state.
|
|
3
|
+
*
|
|
4
|
+
* Each module registers its own emergency-stop function here, so the shell
|
|
5
|
+
* (or any orchestration layer) can stop *every* registered device session in
|
|
6
|
+
* one place, without knowing which modules exist or how each implements its
|
|
7
|
+
* stop.
|
|
8
|
+
*
|
|
9
|
+
* Why it exists: a module that is switched away from gets hidden (it stays
|
|
10
|
+
* mounted so BLE doesn't drop), which means its own stop button is
|
|
11
|
+
* unreachable while its device is *still outputting*. The shell needs a stop
|
|
12
|
+
* anchor that doesn't come and go with module visibility.
|
|
13
|
+
*
|
|
14
|
+
* Three design constraints:
|
|
15
|
+
* 1. `stopAll` must be best-effort — it must NOT abort because one session
|
|
16
|
+
* throws. When one module's BLE is already gone and throws, the other
|
|
17
|
+
* modules' devices are still outputting.
|
|
18
|
+
* 2. The registry is a Map, not an array: on hot reload / remount a module
|
|
19
|
+
* overwrites its entry by id instead of piling up stale closures —
|
|
20
|
+
* otherwise stopAll would invoke functions of unmounted modules.
|
|
21
|
+
* 3. No "remember who was outputting last" optimization. Stop is a
|
|
22
|
+
* low-frequency, high-stakes operation; stopping a few idle devices is
|
|
23
|
+
* fine, missing one because of a state-tracking bug is not.
|
|
24
|
+
*/
|
|
25
|
+
const sessions = new Map();
|
|
26
|
+
const listeners = new Set();
|
|
27
|
+
/**
|
|
28
|
+
* The module currently holding the device lease.
|
|
29
|
+
*
|
|
30
|
+
* `null` means no module holds it — e.g. while sitting on the home screen.
|
|
31
|
+
* Then *no module may issue commands*, but devices stay connected and the
|
|
32
|
+
* stop button stays usable. This matters: surrendering control is not the
|
|
33
|
+
* same as disconnecting.
|
|
34
|
+
*/
|
|
35
|
+
let leaseHolder = null;
|
|
36
|
+
function notify() {
|
|
37
|
+
for (const l of listeners)
|
|
38
|
+
l();
|
|
39
|
+
}
|
|
40
|
+
/** Register a module's device session. Returns an unregister function. */
|
|
41
|
+
export function registerSafetySession(session) {
|
|
42
|
+
sessions.set(session.id, session);
|
|
43
|
+
notify();
|
|
44
|
+
return () => {
|
|
45
|
+
// Delete only while still ours: after a remount the new session has
|
|
46
|
+
// overwritten the old one, and the old cleanup must not remove the new.
|
|
47
|
+
if (sessions.get(session.id) === session) {
|
|
48
|
+
sessions.delete(session.id);
|
|
49
|
+
notify();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** A registered module session, including its optional shell connect action. */
|
|
54
|
+
export function safetySessionById(id) {
|
|
55
|
+
return id ? (sessions.get(id) ?? null) : null;
|
|
56
|
+
}
|
|
57
|
+
/** Modules that currently have an active device session. */
|
|
58
|
+
export function activeSafetySessions() {
|
|
59
|
+
return [...sessions.values()].filter((s) => {
|
|
60
|
+
try {
|
|
61
|
+
return s.isActive();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// If the check itself throws, treat as active — better to show one
|
|
65
|
+
// extra stop button than to miss one.
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export function hasActiveSafetySession() {
|
|
71
|
+
return activeSafetySessions().length > 0;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* All devices currently held, grouped by module.
|
|
75
|
+
*
|
|
76
|
+
* If a module's check throws, skip that module instead of aborting — one
|
|
77
|
+
* module's broken state read must not blank the whole device bar, which
|
|
78
|
+
* would also hide the stop button next to it.
|
|
79
|
+
*/
|
|
80
|
+
export function allConnectedDevices() {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const s of sessions.values()) {
|
|
83
|
+
try {
|
|
84
|
+
const devices = s.devices?.().filter((d) => d.connected) ?? [];
|
|
85
|
+
if (devices.length)
|
|
86
|
+
out.push({ sessionId: s.id, label: s.label, devices });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Skip this one; the rest proceed as normal.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Stop every registered session.
|
|
96
|
+
*
|
|
97
|
+
* allSettled, not all: one module throwing must not abort the others' stops
|
|
98
|
+
* — that is exactly the dangerous case (one device throws on a dropped
|
|
99
|
+
* connection while another keeps outputting).
|
|
100
|
+
*/
|
|
101
|
+
export async function stopAllSafetySessions() {
|
|
102
|
+
const list = [...sessions.values()];
|
|
103
|
+
const results = await Promise.allSettled(
|
|
104
|
+
// The wrapper is required: if stop() throws *synchronously*, the
|
|
105
|
+
// exception escapes inside map and never reaches allSettled — exactly
|
|
106
|
+
// the dangerous case (one device throws on disconnect, none of the
|
|
107
|
+
// others get stopped). The unit test "one session throwing must not
|
|
108
|
+
// abort the others" guards this.
|
|
109
|
+
list.map((s) => Promise.resolve().then(() => s.stop())));
|
|
110
|
+
const failed = results.flatMap((r, i) => r.status === 'rejected' ? [{ id: list[i].id, error: r.reason }] : []);
|
|
111
|
+
return { attempted: list.length, failed };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Hand the device lease to a module. All other modules lose control
|
|
115
|
+
* immediately and are told to stop.
|
|
116
|
+
*
|
|
117
|
+
* Revocation is best-effort: one module's onRevoke throwing must not abort
|
|
118
|
+
* the others' revocation — that is exactly the dangerous case (one module
|
|
119
|
+
* errors while another is still being remotely controlled).
|
|
120
|
+
*/
|
|
121
|
+
export async function grantDeviceLease(moduleId) {
|
|
122
|
+
if (leaseHolder === moduleId)
|
|
123
|
+
return;
|
|
124
|
+
const previous = leaseHolder;
|
|
125
|
+
leaseHolder = moduleId;
|
|
126
|
+
notify();
|
|
127
|
+
if (!previous)
|
|
128
|
+
return;
|
|
129
|
+
const losing = sessions.get(previous);
|
|
130
|
+
if (!losing?.onRevoke)
|
|
131
|
+
return;
|
|
132
|
+
try {
|
|
133
|
+
// `try { await f() }` already catches a synchronous throw — the call
|
|
134
|
+
// itself is inside the try. (stopAllSafetySessions needs the extra
|
|
135
|
+
// wrapper because its call happens inside `map`, outside allSettled —
|
|
136
|
+
// a different situation.)
|
|
137
|
+
await losing.onRevoke();
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// If revocation fails, at least stop it — it no longer holds the lease,
|
|
141
|
+
// but its device may still be outputting.
|
|
142
|
+
try {
|
|
143
|
+
await losing.stop();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Both paths failed: the global stop button is still reachable; that
|
|
147
|
+
// is the last line of defense.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Whether this module currently holds the device lease.
|
|
153
|
+
*
|
|
154
|
+
* Modules check this before *every* device command, not just to disable
|
|
155
|
+
* buttons — remote commands (other room members, AI) never pass through the
|
|
156
|
+
* UI.
|
|
157
|
+
*/
|
|
158
|
+
export function hasDeviceLease(moduleId) {
|
|
159
|
+
return leaseHolder === moduleId;
|
|
160
|
+
}
|
|
161
|
+
export function currentDeviceLease() {
|
|
162
|
+
return leaseHolder;
|
|
163
|
+
}
|
|
164
|
+
/** Subscribe to registry changes (module mount/unmount, lease transfer). */
|
|
165
|
+
export function subscribeSafetySessions(listener) {
|
|
166
|
+
listeners.add(listener);
|
|
167
|
+
return () => listeners.delete(listener);
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=safety-bus.js.map
|