@awak-app/simy-cli 0.7.16 → 0.7.17
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 +180 -26
- package/package.json +3 -2
- package/src/agent.js +128 -7
- package/src/bounded-local-task-subtask-pool.js +32 -6
- package/src/bounded-local-task-subtasks-contract.js +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +2 -2
- package/src/codex-plugins/simy/hooks/README.md +8 -3
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +261 -67
- package/src/codex-plugins/simy/hooks/hooks.json +1 -1
- package/src/codex-plugins/simy/hooks/loop-kernel/conformance.json +136 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/index.mjs +42 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/outcome-review.mjs +31 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/pin.json +6 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/policy.json +141 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/task-pool.mjs +34 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +132 -0
- package/src/codex-plugins/simy/hooks/loop-kernel.mjs +6 -0
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +15 -3
- package/src/codex-plugins/simy/hooks/purpose-completion-contract.mjs +10 -0
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +8 -6
- package/src/codex-plugins/simy/hooks/result-cycle.mjs +32 -6
- package/src/codex-plugins/simy/hooks/work-frontier.mjs +2 -0
- package/src/codex-plugins/simy/skills/diagnose/SKILL.md +10 -4
- package/src/coding-run-recovery.js +4 -2
- package/src/delivery-hooks/claude-workflow.md +10 -7
- package/src/delivery-hooks/command.js +45 -10
- package/src/delivery-hooks/consult.js +60 -0
- package/src/delivery-hooks/diagnosis.js +2 -2
- package/src/delivery-hooks/legacy-hook-routing.js +46 -8
- package/src/delivery-hooks/local-runtime.js +50 -1
- package/src/delivery-hooks/local-supervisor.js +1 -1
- package/src/delivery-hooks/repair-continuation.js +80 -0
- package/src/delivery-hooks/repair.js +164 -0
- package/src/delivery-hooks/supervisor-service.js +3 -1
- package/src/delivery-hooks/supervisor.js +88 -12
- package/src/desktop-access.js +2 -1
- package/src/desktop-host.js +35 -2
- package/src/durable-local-task-worker.js +372 -58
- package/src/execution-capability-contract.js +15 -1
- package/src/index.js +3 -3
- package/src/local-photos-read.swift +33 -0
- package/src/local-photos.js +47 -0
- package/src/local-task-attachment-store.js +31 -0
- package/src/local-task-destination.js +174 -0
- package/src/local-task.js +119 -7
- package/src/native-delegation-contract.js +68 -0
- package/src/native-provider-identity.js +60 -0
- package/src/orchestrator/budget.js +22 -11
- package/src/orchestrator/independent-audit.js +9 -18
- package/src/orchestrator/loop.js +27 -59
- package/src/orchestrator/recovery.js +32 -2
- package/src/provider-delegation.js +336 -0
- package/src/runner.js +2 -1
- package/src/session-supervision/artifact-observer.js +138 -0
- package/src/session-supervision/codex-app-server.js +19 -5
- package/src/session-supervision/completion-artifacts.js +44 -0
- package/src/session-supervision/consistency-auditor.js +8 -11
- package/src/session-supervision/continuation-analysis.js +46 -28
- package/src/session-supervision/final-response-recovery.js +5 -3
- package/src/session-supervision/index.js +22 -5
- package/src/session-supervision/purpose-recovery.js +12 -3
- package/src/session-supervision/purpose-verification.js +9 -57
- package/src/session-supervision/recovery-frontier.js +55 -0
- package/src/session-supervision/stop-notifications.js +8 -8
- package/src/sqm/agent-skill.js +1 -1
- package/src/sqm/checkers.js +12 -6
- package/src/sqm/claude-plugin-update.js +22 -8
- package/src/sqm/claude-plugin.js +4 -0
- package/src/sqm/local-tests.js +2 -2
- package/src/sqm/plugin-cache.js +67 -0
- package/src/sqm/proof.js +25 -0
- package/src/sqm/report.js +1 -0
- package/src/sqm/workflow-onboarding.js +3 -39
- package/src/sqm/workflow-update.js +17 -6
- package/src/team-status-fence-store.js +32 -0
- package/src/team-threads.js +457 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
function profileAvatar(value) {
|
|
2
|
+
if (typeof value !== "string" || value.length > 2048) return {};
|
|
3
|
+
try {
|
|
4
|
+
const url = new URL(value);
|
|
5
|
+
if (url.protocol !== "https:" || !url.hostname || url.username || url.password) return {};
|
|
6
|
+
return { avatarUrl: url.toString() };
|
|
7
|
+
} catch { return {}; }
|
|
8
|
+
}
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { sanitizedText } from "./session-supervision/conversation-context.js";
|
|
11
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
12
|
+
const keyPattern = /^[a-f0-9]{64}$/;
|
|
13
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
14
|
+
const empty = () => ({
|
|
15
|
+
state: "loading",
|
|
16
|
+
members: [],
|
|
17
|
+
threads: [],
|
|
18
|
+
own: [],
|
|
19
|
+
detail: null,
|
|
20
|
+
pendingStops: [],
|
|
21
|
+
memberId: null,
|
|
22
|
+
threadsTruncated: false,
|
|
23
|
+
});
|
|
24
|
+
const invalid = () => {
|
|
25
|
+
throw new Error("Shared conversations unavailable");
|
|
26
|
+
};
|
|
27
|
+
export const teamThreadKey = (scope, candidate) =>
|
|
28
|
+
digest(
|
|
29
|
+
JSON.stringify([scope.fenceKey, candidate.provider, candidate.session]),
|
|
30
|
+
);
|
|
31
|
+
function projectSummary(row) {
|
|
32
|
+
if (
|
|
33
|
+
!uuid.test(row?.memberId) ||
|
|
34
|
+
!keyPattern.test(row?.threadKey) ||
|
|
35
|
+
(row.parentThreadKey != null && (!keyPattern.test(row.parentThreadKey) || row.parentThreadKey === row.threadKey)) ||
|
|
36
|
+
typeof row.title !== "string" ||
|
|
37
|
+
!row.title.trim() ||
|
|
38
|
+
row.title.length > 240 ||
|
|
39
|
+
!["running", "needs_attention", "unknown"].includes(row.state) ||
|
|
40
|
+
!Number.isFinite(Date.parse(row.observedAt))
|
|
41
|
+
)
|
|
42
|
+
invalid();
|
|
43
|
+
return {
|
|
44
|
+
memberId: row.memberId,
|
|
45
|
+
threadKey: row.threadKey,
|
|
46
|
+
parentThreadKey: row.parentThreadKey ?? null,
|
|
47
|
+
title: sanitizedText(row.title),
|
|
48
|
+
state: row.state,
|
|
49
|
+
observedAt: row.observedAt,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function projectThreadContext(result, org) {
|
|
53
|
+
if (
|
|
54
|
+
result?.schemaVersion !== 1 ||
|
|
55
|
+
result.organizationId !== org ||
|
|
56
|
+
!Array.isArray(result.members) ||
|
|
57
|
+
result.members.length > 2000 ||
|
|
58
|
+
!Array.isArray(result.threads) ||
|
|
59
|
+
result.threads.length > 200 ||
|
|
60
|
+
!Array.isArray(result.own) ||
|
|
61
|
+
result.own.length > 200
|
|
62
|
+
)
|
|
63
|
+
invalid();
|
|
64
|
+
const members = result.members.map((row) => {
|
|
65
|
+
if (
|
|
66
|
+
!uuid.test(row?.id) ||
|
|
67
|
+
typeof row.label !== "string" ||
|
|
68
|
+
!row.label.trim() ||
|
|
69
|
+
row.label.length > 240
|
|
70
|
+
)
|
|
71
|
+
invalid();
|
|
72
|
+
return { id: row.id, label: sanitizedText(row.label), ...profileAvatar(row.avatarUrl) };
|
|
73
|
+
});
|
|
74
|
+
const own = result.own.map((row) => {
|
|
75
|
+
if (
|
|
76
|
+
!keyPattern.test(row?.threadKey) ||
|
|
77
|
+
!Number.isSafeInteger(row.version) ||
|
|
78
|
+
row.version < 0 ||
|
|
79
|
+
typeof row.enabled !== "boolean"
|
|
80
|
+
)
|
|
81
|
+
invalid();
|
|
82
|
+
return {
|
|
83
|
+
threadKey: row.threadKey,
|
|
84
|
+
version: row.version,
|
|
85
|
+
enabled: row.enabled,
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
const threads = result.threads.map(projectSummary);
|
|
89
|
+
if (
|
|
90
|
+
new Set(members.map((x) => x.id)).size !== members.length ||
|
|
91
|
+
new Set(own.map((x) => x.threadKey)).size !== own.length
|
|
92
|
+
)
|
|
93
|
+
invalid();
|
|
94
|
+
return { members, own, threads };
|
|
95
|
+
}
|
|
96
|
+
export function createTeamThreads({
|
|
97
|
+
readScope,
|
|
98
|
+
request,
|
|
99
|
+
fenceStore,
|
|
100
|
+
readContext = async (...args) => (await import("./session-supervision/continuation-analysis.js")).readContinuationContext(...args),
|
|
101
|
+
onChange = () => {},
|
|
102
|
+
now = Date.now,
|
|
103
|
+
pollMs = 30000,
|
|
104
|
+
}) {
|
|
105
|
+
let value = empty(),
|
|
106
|
+
scopeKey,
|
|
107
|
+
generation = 0,
|
|
108
|
+
controller,
|
|
109
|
+
busy = false,
|
|
110
|
+
closed = false,
|
|
111
|
+
lastPoll = -Infinity,
|
|
112
|
+
candidates = [],
|
|
113
|
+
selected = null,
|
|
114
|
+
memberFilter = null,
|
|
115
|
+
pending = new Map(),
|
|
116
|
+
storageFailed = false,
|
|
117
|
+
lastAllowed = false;
|
|
118
|
+
function scope() {
|
|
119
|
+
const current = readScope();
|
|
120
|
+
if (scopeKey !== current.key || (lastAllowed && !current.allowed)) {
|
|
121
|
+
controller?.abort();
|
|
122
|
+
generation++;
|
|
123
|
+
const sameIdentity = scopeKey === current.key;
|
|
124
|
+
scopeKey = current.key;
|
|
125
|
+
const own = sameIdentity ? value.own : [];
|
|
126
|
+
value = { ...empty(), own };
|
|
127
|
+
selected = null;
|
|
128
|
+
memberFilter = null;
|
|
129
|
+
if (!sameIdentity) pending = new Map();
|
|
130
|
+
lastPoll = -Infinity;
|
|
131
|
+
}
|
|
132
|
+
lastAllowed = current.allowed;
|
|
133
|
+
if (!current.allowed) value.state = "unavailable";
|
|
134
|
+
return current;
|
|
135
|
+
}
|
|
136
|
+
const snapshot = () => {
|
|
137
|
+
scope();
|
|
138
|
+
return structuredClone({ ...value, pendingStops: [...pending.keys()] });
|
|
139
|
+
};
|
|
140
|
+
const fenceKey = (current, key) => digest(`${current.fenceKey}:${key}`);
|
|
141
|
+
async function run(fn, allowOff = false) {
|
|
142
|
+
const current = scope();
|
|
143
|
+
if (
|
|
144
|
+
busy ||
|
|
145
|
+
closed ||
|
|
146
|
+
!current.authenticated ||
|
|
147
|
+
(!current.allowed && !allowOff)
|
|
148
|
+
)
|
|
149
|
+
return false;
|
|
150
|
+
busy = true;
|
|
151
|
+
controller = new AbortController();
|
|
152
|
+
const own = controller,
|
|
153
|
+
epoch = generation;
|
|
154
|
+
const valid = () =>
|
|
155
|
+
!closed &&
|
|
156
|
+
generation === epoch &&
|
|
157
|
+
readScope().key === current.key &&
|
|
158
|
+
readScope().authenticated &&
|
|
159
|
+
(readScope().allowed || allowOff) &&
|
|
160
|
+
!own.signal.aborted;
|
|
161
|
+
const send = async (body) => {
|
|
162
|
+
if (!valid()) invalid();
|
|
163
|
+
const result = await request(body, own.signal);
|
|
164
|
+
if (
|
|
165
|
+
!valid() ||
|
|
166
|
+
result?.schemaVersion !== 1 ||
|
|
167
|
+
result.organizationId !== current.organizationId
|
|
168
|
+
)
|
|
169
|
+
invalid();
|
|
170
|
+
return result;
|
|
171
|
+
};
|
|
172
|
+
try {
|
|
173
|
+
await fn({ current, send, valid, signal: own.signal });
|
|
174
|
+
return valid();
|
|
175
|
+
} catch {
|
|
176
|
+
if (valid()) {
|
|
177
|
+
value = {
|
|
178
|
+
...empty(),
|
|
179
|
+
own: value.own,
|
|
180
|
+
state: storageFailed ? "local_storage" : "unavailable",
|
|
181
|
+
};
|
|
182
|
+
onChange();
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
} finally {
|
|
186
|
+
busy = false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async function context(send) {
|
|
190
|
+
const raw = await send({
|
|
191
|
+
operation: "context",
|
|
192
|
+
...(memberFilter ? { memberId: memberFilter } : {}),
|
|
193
|
+
});
|
|
194
|
+
const result = projectThreadContext(raw, readScope().organizationId);
|
|
195
|
+
if (
|
|
196
|
+
(raw.memberId ?? null) !== memberFilter ||
|
|
197
|
+
typeof raw.threadsTruncated !== "boolean"
|
|
198
|
+
)
|
|
199
|
+
invalid();
|
|
200
|
+
value = {
|
|
201
|
+
...value,
|
|
202
|
+
...result,
|
|
203
|
+
memberId: memberFilter,
|
|
204
|
+
threadsTruncated: raw.threadsTruncated,
|
|
205
|
+
state: "ready",
|
|
206
|
+
};
|
|
207
|
+
if (selected && !raw.threadsTruncated && !value.threads.some(row => row.memberId === selected.memberId && row.threadKey === selected.threadKey)) {
|
|
208
|
+
selected = null;
|
|
209
|
+
value.detail = null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function clearStop(current, key) {
|
|
213
|
+
await fenceStore.clear(fenceKey(current, key));
|
|
214
|
+
pending.delete(key);
|
|
215
|
+
}
|
|
216
|
+
async function stop(send, current, key, version) {
|
|
217
|
+
const result = await send({
|
|
218
|
+
operation: "consent",
|
|
219
|
+
threadKey: key,
|
|
220
|
+
expectedVersion: version,
|
|
221
|
+
enabled: false,
|
|
222
|
+
});
|
|
223
|
+
if (
|
|
224
|
+
result.threadKey !== key ||
|
|
225
|
+
result.enabled !== false ||
|
|
226
|
+
!Number.isSafeInteger(result.version) ||
|
|
227
|
+
result.version <= version
|
|
228
|
+
)
|
|
229
|
+
invalid();
|
|
230
|
+
await clearStop(current, key);
|
|
231
|
+
value.own = value.own.map((row) =>
|
|
232
|
+
row.threadKey === key
|
|
233
|
+
? { ...row, enabled: false, version: result.version }
|
|
234
|
+
: row,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
async function readSelected(send) {
|
|
238
|
+
if (!selected) return;
|
|
239
|
+
const selection = { ...selected };
|
|
240
|
+
const result = await send({ operation: "read", ...selection });
|
|
241
|
+
if (
|
|
242
|
+
selected?.memberId !== selection.memberId ||
|
|
243
|
+
selected?.threadKey !== selection.threadKey
|
|
244
|
+
)
|
|
245
|
+
return;
|
|
246
|
+
const summary = projectSummary(result);
|
|
247
|
+
if (
|
|
248
|
+
summary.memberId !== selection.memberId ||
|
|
249
|
+
summary.threadKey !== selection.threadKey ||
|
|
250
|
+
!Array.isArray(result.messages) ||
|
|
251
|
+
result.messages.length > 32 ||
|
|
252
|
+
typeof result.truncated !== "boolean" ||
|
|
253
|
+
typeof result.incomplete !== "boolean"
|
|
254
|
+
)
|
|
255
|
+
invalid();
|
|
256
|
+
let total = 0;
|
|
257
|
+
const messages = result.messages.map((row) => {
|
|
258
|
+
if (
|
|
259
|
+
!["user", "assistant"].includes(row?.role) ||
|
|
260
|
+
typeof row.text !== "string" ||
|
|
261
|
+
row.text.length > 4000
|
|
262
|
+
)
|
|
263
|
+
invalid();
|
|
264
|
+
total += row.text.length;
|
|
265
|
+
return { role: row.role, text: sanitizedText(row.text) };
|
|
266
|
+
});
|
|
267
|
+
if (total > 24000) invalid();
|
|
268
|
+
value.detail = {
|
|
269
|
+
...summary,
|
|
270
|
+
messages,
|
|
271
|
+
truncated: result.truncated,
|
|
272
|
+
incomplete: result.incomplete,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
async function payload(candidate, signal) {
|
|
276
|
+
const result = await readContext(candidate, { signal });
|
|
277
|
+
if (!Array.isArray(result?.messages) || !result.messages.length) invalid();
|
|
278
|
+
// Existing provider readers exclude attachments/tool payloads and bound prose.
|
|
279
|
+
const messages = result.messages.map(({ role, text }) => ({ role, text }));
|
|
280
|
+
const observation = candidate.observation;
|
|
281
|
+
const running =
|
|
282
|
+
candidate.isRunning === true &&
|
|
283
|
+
observation?.source === "codex_app_server" &&
|
|
284
|
+
Number.isFinite(observation.observedAt) &&
|
|
285
|
+
now() - observation.observedAt >= 0 &&
|
|
286
|
+
now() - observation.observedAt <= 300000;
|
|
287
|
+
const parent = candidate.parentSession;
|
|
288
|
+
const parentThreadKey = parent && typeof parent.provider === "string" && typeof parent.session === "string" && parent.session
|
|
289
|
+
&& !(parent.provider === candidate.provider && parent.session === candidate.session)
|
|
290
|
+
? teamThreadKey(readScope(), parent) : null;
|
|
291
|
+
return {
|
|
292
|
+
parentThreadKey,
|
|
293
|
+
title: sanitizedText(candidate.title || "Work conversation").slice(
|
|
294
|
+
0,
|
|
295
|
+
240,
|
|
296
|
+
),
|
|
297
|
+
state: running ? "running" : "unknown",
|
|
298
|
+
observedAt: new Date(now()).toISOString(),
|
|
299
|
+
messages,
|
|
300
|
+
truncated: result.truncated === true,
|
|
301
|
+
incomplete: result.incomplete === true,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
async function refresh({ publish = false, memberId = undefined } = {}) {
|
|
305
|
+
if (memberId !== undefined && memberFilter !== (memberId || null)) {
|
|
306
|
+
scope();
|
|
307
|
+
controller?.abort();
|
|
308
|
+
generation++;
|
|
309
|
+
memberFilter = memberId || null;
|
|
310
|
+
selected = null;
|
|
311
|
+
value.threads = [];
|
|
312
|
+
value.detail = null;
|
|
313
|
+
value.memberId = memberFilter;
|
|
314
|
+
onChange();
|
|
315
|
+
while (busy && !closed)
|
|
316
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
317
|
+
}
|
|
318
|
+
return run(async ({ current, send, valid, signal }) => {
|
|
319
|
+
lastPoll = now();
|
|
320
|
+
await context(send);
|
|
321
|
+
for (const item of value.own) {
|
|
322
|
+
const fence = await fenceStore.read(fenceKey(current, item.threadKey));
|
|
323
|
+
if (!valid()) return;
|
|
324
|
+
if (fence) {
|
|
325
|
+
pending.set(item.threadKey, fence.expectedVersion);
|
|
326
|
+
await stop(send, current, item.threadKey, fence.expectedVersion);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (publish && !storageFailed)
|
|
330
|
+
for (const item of [...value.own]) {
|
|
331
|
+
if (!item.enabled || pending.has(item.threadKey)) continue;
|
|
332
|
+
const candidate = candidates.find(
|
|
333
|
+
(c) => teamThreadKey(current, c) === item.threadKey,
|
|
334
|
+
);
|
|
335
|
+
if (!candidate) continue;
|
|
336
|
+
let body;
|
|
337
|
+
try {
|
|
338
|
+
body = await payload(candidate, signal);
|
|
339
|
+
} catch {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (!valid() || pending.has(item.threadKey)) return;
|
|
343
|
+
const receipt = await send({
|
|
344
|
+
operation: "publish",
|
|
345
|
+
threadKey: item.threadKey,
|
|
346
|
+
expectedVersion: item.version,
|
|
347
|
+
...body,
|
|
348
|
+
});
|
|
349
|
+
if (
|
|
350
|
+
receipt.threadKey !== item.threadKey ||
|
|
351
|
+
receipt.enabled !== true ||
|
|
352
|
+
receipt.version !== item.version
|
|
353
|
+
)
|
|
354
|
+
invalid();
|
|
355
|
+
}
|
|
356
|
+
await readSelected(send);
|
|
357
|
+
if (valid()) onChange();
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
snapshot,
|
|
362
|
+
tick(sessions) {
|
|
363
|
+
candidates = sessions;
|
|
364
|
+
scope();
|
|
365
|
+
if (!busy && !closed && now() - lastPoll >= pollMs) {
|
|
366
|
+
lastPoll = now();
|
|
367
|
+
void refresh({ publish: true });
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
refresh,
|
|
371
|
+
async open({ memberId, threadKey }) {
|
|
372
|
+
scope();
|
|
373
|
+
selected = memberId && threadKey ? { memberId, threadKey } : null;
|
|
374
|
+
value.detail = null;
|
|
375
|
+
onChange();
|
|
376
|
+
if (!selected) return true;
|
|
377
|
+
// An in-flight context/publish must not replace this newer selection.
|
|
378
|
+
controller?.abort();
|
|
379
|
+
generation++;
|
|
380
|
+
while (busy && !closed)
|
|
381
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
382
|
+
return run(async ({ send, valid }) => {
|
|
383
|
+
await readSelected(send);
|
|
384
|
+
if (valid()) onChange();
|
|
385
|
+
});
|
|
386
|
+
},
|
|
387
|
+
async setSharing(candidate, enabled) {
|
|
388
|
+
const current = scope(),
|
|
389
|
+
key = teamThreadKey(current, candidate),
|
|
390
|
+
item = value.own.find((row) => row.threadKey === key),
|
|
391
|
+
version = item?.version ?? 0;
|
|
392
|
+
if (
|
|
393
|
+
typeof enabled !== "boolean" ||
|
|
394
|
+
(enabled && (!current.allowed || busy || pending.has(key)))
|
|
395
|
+
)
|
|
396
|
+
return false;
|
|
397
|
+
if (enabled && storageFailed) {
|
|
398
|
+
// An OFF acknowledgement confirms server removal, not writable local
|
|
399
|
+
// storage. Revalidate durability before accepting a later explicit ON.
|
|
400
|
+
if (item?.enabled !== false) return false;
|
|
401
|
+
// Probe a distinct opaque key so a concurrent OFF cannot lose its fence.
|
|
402
|
+
const probe = digest(`storage-probe:${current.fenceKey}`);
|
|
403
|
+
try {
|
|
404
|
+
await fenceStore.write(probe, 0);
|
|
405
|
+
await fenceStore.clear(probe);
|
|
406
|
+
} catch { return false; }
|
|
407
|
+
if (readScope().key !== current.key || pending.has(key)) return false;
|
|
408
|
+
storageFailed = false;
|
|
409
|
+
}
|
|
410
|
+
if (!enabled) {
|
|
411
|
+
pending.set(key, version);
|
|
412
|
+
controller?.abort();
|
|
413
|
+
generation++;
|
|
414
|
+
onChange();
|
|
415
|
+
try {
|
|
416
|
+
await fenceStore.write(fenceKey(current, key), version);
|
|
417
|
+
} catch {
|
|
418
|
+
storageFailed = true;
|
|
419
|
+
value.state = "local_storage";
|
|
420
|
+
onChange();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
while (busy && !closed)
|
|
424
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
425
|
+
if (readScope().key !== current.key || !readScope().authenticated) return false;
|
|
426
|
+
return run(async ({ current, send, valid, signal }) => {
|
|
427
|
+
if (!enabled) await stop(send, current, key, version);
|
|
428
|
+
else {
|
|
429
|
+
const fence = await fenceStore.read(fenceKey(current, key));
|
|
430
|
+
if (fence) invalid();
|
|
431
|
+
const body = await payload(candidate, signal);
|
|
432
|
+
const receipt = await send({
|
|
433
|
+
operation: "consent",
|
|
434
|
+
threadKey: key,
|
|
435
|
+
expectedVersion: version,
|
|
436
|
+
enabled: true,
|
|
437
|
+
...body,
|
|
438
|
+
});
|
|
439
|
+
if (
|
|
440
|
+
receipt.threadKey !== key ||
|
|
441
|
+
receipt.enabled !== true ||
|
|
442
|
+
receipt.version !== version + 1
|
|
443
|
+
)
|
|
444
|
+
invalid();
|
|
445
|
+
}
|
|
446
|
+
if (current.allowed) await context(send);
|
|
447
|
+
if (valid()) onChange();
|
|
448
|
+
}, !enabled);
|
|
449
|
+
},
|
|
450
|
+
close() {
|
|
451
|
+
closed = true;
|
|
452
|
+
controller?.abort();
|
|
453
|
+
generation++;
|
|
454
|
+
value = empty();
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|