@awak-app/simy-cli 0.5.42 → 0.5.44
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/package.json +3 -3
- package/src/agent.js +93 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +16 -1
- package/src/delivery-hooks/supervisor.js +5 -1
- package/src/desktop-host.js +65 -23
- package/src/session-store.js +1 -1
- package/src/session-supervision/action-association-host.js +37 -4
- package/src/session-supervision/action-deadline-store.js +57 -0
- package/src/session-supervision/action-deadline.js +310 -0
- package/src/session-supervision/association-store.js +369 -0
- package/src/session-supervision/automatic-continuation.js +81 -0
- package/src/session-supervision/index.js +56 -8
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createActionDeadlineStore } from "./action-deadline-store.js";
|
|
3
|
+
|
|
4
|
+
const terminal = new Set([
|
|
5
|
+
"completed",
|
|
6
|
+
"complete",
|
|
7
|
+
"done",
|
|
8
|
+
"cancelled",
|
|
9
|
+
"canceled",
|
|
10
|
+
"failed",
|
|
11
|
+
"archived",
|
|
12
|
+
"deleted",
|
|
13
|
+
]);
|
|
14
|
+
const hash = (value) =>
|
|
15
|
+
createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
16
|
+
const identity = (context) => {
|
|
17
|
+
const result = [
|
|
18
|
+
context.namespace,
|
|
19
|
+
context.provider,
|
|
20
|
+
context.session,
|
|
21
|
+
context.taskId,
|
|
22
|
+
];
|
|
23
|
+
if (result.some((value) => typeof value !== "string" || !value))
|
|
24
|
+
throw new Error("A scoped session and task identity is required.");
|
|
25
|
+
return result;
|
|
26
|
+
};
|
|
27
|
+
// A date-only Action is due at the next local midnight, including DST days.
|
|
28
|
+
// Explicit timestamps must carry their own offset; host local timezone is never used.
|
|
29
|
+
export function actionDueInstant(dueAt, timeZone) {
|
|
30
|
+
if (typeof dueAt !== "string") return null;
|
|
31
|
+
try {
|
|
32
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(dueAt)) {
|
|
33
|
+
if (typeof timeZone !== "string" || !timeZone) return null;
|
|
34
|
+
const format = new Intl.DateTimeFormat("en-CA", {
|
|
35
|
+
timeZone,
|
|
36
|
+
year: "numeric",
|
|
37
|
+
month: "2-digit",
|
|
38
|
+
day: "2-digit",
|
|
39
|
+
});
|
|
40
|
+
const start = Date.parse(`${dueAt}T00:00:00Z`);
|
|
41
|
+
if (
|
|
42
|
+
!Number.isFinite(start) ||
|
|
43
|
+
new Date(start).toISOString().slice(0, 10) !== dueAt
|
|
44
|
+
)
|
|
45
|
+
return null;
|
|
46
|
+
const localDate = (instant) => {
|
|
47
|
+
const parts = Object.fromEntries(
|
|
48
|
+
format.formatToParts(instant).map((part) => [part.type, part.value]),
|
|
49
|
+
);
|
|
50
|
+
return `${parts.year}-${parts.month}-${parts.day}`;
|
|
51
|
+
};
|
|
52
|
+
let low = start - 36 * 3600000,
|
|
53
|
+
high = start + 60 * 3600000;
|
|
54
|
+
while (high - low > 1) {
|
|
55
|
+
const mid = Math.floor((low + high) / 2);
|
|
56
|
+
if (localDate(mid) <= dueAt) low = mid;
|
|
57
|
+
else high = mid;
|
|
58
|
+
}
|
|
59
|
+
return high;
|
|
60
|
+
}
|
|
61
|
+
if (!/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/i.test(dueAt))
|
|
62
|
+
return null;
|
|
63
|
+
const calendar = dueAt.slice(0, 10),
|
|
64
|
+
calendarInstant = Date.parse(`${calendar}T00:00:00Z`);
|
|
65
|
+
if (
|
|
66
|
+
!Number.isFinite(calendarInstant) ||
|
|
67
|
+
new Date(calendarInstant).toISOString().slice(0, 10) !== calendar
|
|
68
|
+
)
|
|
69
|
+
return null;
|
|
70
|
+
const instant = Date.parse(dueAt);
|
|
71
|
+
return Number.isFinite(instant) ? instant : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const options = [
|
|
77
|
+
{
|
|
78
|
+
id: "keep_scope",
|
|
79
|
+
title: "Keep the current scope",
|
|
80
|
+
description: "Continue the remaining work with the current deadline.",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "revise_deadline",
|
|
84
|
+
title: "Propose a new deadline",
|
|
85
|
+
description: "Discuss a new deadline before changing the Action.",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "reduce_scope",
|
|
89
|
+
title: "Propose a smaller scope",
|
|
90
|
+
description: "Discuss what to defer before changing the Action.",
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
export function createActionDeadlineService({
|
|
94
|
+
root,
|
|
95
|
+
now = Date.now,
|
|
96
|
+
store = createActionDeadlineStore({ root }),
|
|
97
|
+
reminderLeadMs = 24 * 3600000,
|
|
98
|
+
} = {}) {
|
|
99
|
+
const transact = async (context, update) => {
|
|
100
|
+
try {
|
|
101
|
+
return await store.transact(
|
|
102
|
+
identity(context),
|
|
103
|
+
update,
|
|
104
|
+
context.validate || context.isCurrent,
|
|
105
|
+
);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error.code === "conflict")
|
|
108
|
+
return {
|
|
109
|
+
status: "conflict",
|
|
110
|
+
reminder: null,
|
|
111
|
+
decision: null,
|
|
112
|
+
httpStatus: 409,
|
|
113
|
+
};
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const view = (state, context) => ({
|
|
118
|
+
...state,
|
|
119
|
+
online: context.online !== false,
|
|
120
|
+
remoteApplied: false,
|
|
121
|
+
persistence: "local",
|
|
122
|
+
options,
|
|
123
|
+
remainingWork: context.remainingWork ?? null,
|
|
124
|
+
blocker: context.blocker ?? null,
|
|
125
|
+
plan: context.plan ?? null,
|
|
126
|
+
recommendation: {
|
|
127
|
+
optionId: context.blocker ? "revise_deadline" : "keep_scope",
|
|
128
|
+
rationale: context.blocker
|
|
129
|
+
? "Resolve the blocker and discuss a realistic deadline."
|
|
130
|
+
: "Continue the known remaining work; no completion forecast is available.",
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
async function reconcile(context) {
|
|
134
|
+
const key = identity(context),
|
|
135
|
+
action = context.binding?.action;
|
|
136
|
+
const dueInstant = actionDueInstant(action?.dueAt, action?.timeZone);
|
|
137
|
+
const bindingRevision =
|
|
138
|
+
context.binding?.revision ?? context.bindingRevision ?? null;
|
|
139
|
+
const fingerprint = hash([
|
|
140
|
+
action?.id,
|
|
141
|
+
action?.revision,
|
|
142
|
+
bindingRevision,
|
|
143
|
+
action?.dueAt,
|
|
144
|
+
action?.timeZone,
|
|
145
|
+
action?.status,
|
|
146
|
+
terminal.has(context.taskStatus),
|
|
147
|
+
]);
|
|
148
|
+
const state = await transact(context, (previous) => {
|
|
149
|
+
if (context.online === false) return previous;
|
|
150
|
+
// Tombstones retain the last authoritative revision, so old snapshots
|
|
151
|
+
// cannot clear a newer binding or resurrect a previously unlinked Action.
|
|
152
|
+
if (
|
|
153
|
+
previous?.bindingRevision != null &&
|
|
154
|
+
(bindingRevision == null
|
|
155
|
+
? !(context.validate || context.isCurrent)
|
|
156
|
+
: previous.bindingRevision > bindingRevision)
|
|
157
|
+
)
|
|
158
|
+
return previous;
|
|
159
|
+
if (previous?.fingerprint === fingerprint) return previous;
|
|
160
|
+
const revision = (previous?.revision || 0) + 1;
|
|
161
|
+
const base = {
|
|
162
|
+
identity: key,
|
|
163
|
+
fingerprint,
|
|
164
|
+
revision,
|
|
165
|
+
bindingRevision: bindingRevision ?? previous?.bindingRevision ?? null,
|
|
166
|
+
actionId: action?.id ?? null,
|
|
167
|
+
actionRevision: action?.revision ?? null,
|
|
168
|
+
dueAt: action?.dueAt ?? null,
|
|
169
|
+
timeZone: action?.timeZone ?? null,
|
|
170
|
+
reminder: null,
|
|
171
|
+
decision: null,
|
|
172
|
+
};
|
|
173
|
+
if (
|
|
174
|
+
!action ||
|
|
175
|
+
terminal.has(action.status) ||
|
|
176
|
+
terminal.has(context.taskStatus)
|
|
177
|
+
)
|
|
178
|
+
return { ...base, status: "hidden" };
|
|
179
|
+
if (action.dueAt == null || action.dueAt === "") return { ...base, status: "hidden" };
|
|
180
|
+
if (dueInstant === null)
|
|
181
|
+
return {
|
|
182
|
+
...base,
|
|
183
|
+
status: "unavailable",
|
|
184
|
+
reason: "The Action deadline needs a valid date and timezone.",
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
...base,
|
|
188
|
+
dueInstant,
|
|
189
|
+
status: "upcoming",
|
|
190
|
+
reminder: {
|
|
191
|
+
key: hash([...key, fingerprint]),
|
|
192
|
+
revision,
|
|
193
|
+
dueAt: action.dueAt,
|
|
194
|
+
timeZone: action.timeZone,
|
|
195
|
+
dueInstant,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
return project(state, context);
|
|
200
|
+
}
|
|
201
|
+
function project(state, context) {
|
|
202
|
+
if (!state)
|
|
203
|
+
return view(
|
|
204
|
+
{
|
|
205
|
+
status: context.online === false ? "unavailable" : "hidden",
|
|
206
|
+
reminder: null,
|
|
207
|
+
decision: null,
|
|
208
|
+
},
|
|
209
|
+
context,
|
|
210
|
+
);
|
|
211
|
+
const status =
|
|
212
|
+
context.online === false
|
|
213
|
+
? "unavailable"
|
|
214
|
+
: state.decision
|
|
215
|
+
? "decided"
|
|
216
|
+
: state.reminder && now() >= state.dueInstant
|
|
217
|
+
? "overdue"
|
|
218
|
+
: state.reminder && now() >= state.dueInstant - reminderLeadMs
|
|
219
|
+
? "due"
|
|
220
|
+
: state.status;
|
|
221
|
+
return view({ ...state, status }, context);
|
|
222
|
+
}
|
|
223
|
+
// Saving a local choice does not edit the remote Action deadline or scope.
|
|
224
|
+
async function decide(context, payload) {
|
|
225
|
+
const key = identity(context);
|
|
226
|
+
if (payload.cancel === true)
|
|
227
|
+
return { ...(await read(context)), cancelled: true };
|
|
228
|
+
let conflict = false;
|
|
229
|
+
const state = await transact(context, (previous) => {
|
|
230
|
+
const action = context.binding?.action;
|
|
231
|
+
if (
|
|
232
|
+
context.online === false ||
|
|
233
|
+
!previous?.reminder ||
|
|
234
|
+
terminal.has(context.taskStatus) ||
|
|
235
|
+
terminal.has(action?.status) ||
|
|
236
|
+
previous.reminder.key !== payload.reminderKey ||
|
|
237
|
+
previous.reminder.revision !== payload.reminderRevision ||
|
|
238
|
+
previous.actionId !== action?.id ||
|
|
239
|
+
(payload.actionId && payload.actionId !== action.id) ||
|
|
240
|
+
previous.actionRevision !== action?.revision ||
|
|
241
|
+
previous.actionRevision !== payload.expectedActionRevision ||
|
|
242
|
+
previous.bindingRevision !== context.binding?.revision ||
|
|
243
|
+
previous.bindingRevision !== payload.expectedBindingRevision ||
|
|
244
|
+
previous.dueAt !== action?.dueAt ||
|
|
245
|
+
previous.timeZone !== action?.timeZone
|
|
246
|
+
) {
|
|
247
|
+
conflict = true;
|
|
248
|
+
return previous;
|
|
249
|
+
}
|
|
250
|
+
const requestId = payload.requestId || payload.decisionId;
|
|
251
|
+
if (
|
|
252
|
+
typeof requestId !== "string" ||
|
|
253
|
+
!requestId ||
|
|
254
|
+
!options.some((option) => option.id === payload.choice)
|
|
255
|
+
) {
|
|
256
|
+
conflict = true;
|
|
257
|
+
return previous;
|
|
258
|
+
}
|
|
259
|
+
const payloadRevision = hash([
|
|
260
|
+
payload.actionId ?? null,
|
|
261
|
+
payload.reminderKey,
|
|
262
|
+
payload.reminderRevision,
|
|
263
|
+
payload.expectedActionRevision,
|
|
264
|
+
payload.expectedBindingRevision,
|
|
265
|
+
payload.choice,
|
|
266
|
+
payload.expectedDecisionRevision ?? null,
|
|
267
|
+
]);
|
|
268
|
+
if (Object.hasOwn(previous.requests || {}, requestId)) {
|
|
269
|
+
if (previous.requests[requestId].payloadRevision !== payloadRevision)
|
|
270
|
+
conflict = true;
|
|
271
|
+
return previous;
|
|
272
|
+
}
|
|
273
|
+
if (previous.decision?.requestId === requestId) {
|
|
274
|
+
if (previous.decision.choice !== payload.choice) conflict = true;
|
|
275
|
+
return previous;
|
|
276
|
+
}
|
|
277
|
+
if (
|
|
278
|
+
previous.decision &&
|
|
279
|
+
payload.expectedDecisionRevision !== previous.decision.revision
|
|
280
|
+
) {
|
|
281
|
+
conflict = true;
|
|
282
|
+
return previous;
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
...previous,
|
|
286
|
+
status: "decided",
|
|
287
|
+
requests: {
|
|
288
|
+
...previous.requests,
|
|
289
|
+
[requestId]: { choice: payload.choice, payloadRevision },
|
|
290
|
+
},
|
|
291
|
+
decision: {
|
|
292
|
+
id: requestId,
|
|
293
|
+
requestId,
|
|
294
|
+
choice: payload.choice,
|
|
295
|
+
revision: (previous.decision?.revision || 0) + 1,
|
|
296
|
+
reminderKey: previous.reminder.key,
|
|
297
|
+
createdAt: now(),
|
|
298
|
+
remoteApplied: false,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
return conflict
|
|
303
|
+
? { ...project(state, context), status: "conflict", httpStatus: 409 }
|
|
304
|
+
: project(state, context);
|
|
305
|
+
}
|
|
306
|
+
async function read(context) {
|
|
307
|
+
return project(await store.read(identity(context)), context);
|
|
308
|
+
}
|
|
309
|
+
return { reconcile, read, decide };
|
|
310
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
listRecords,
|
|
4
|
+
updateRecord,
|
|
5
|
+
} from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
6
|
+
import { associationRevision } from './action-association.js';
|
|
7
|
+
import { ActionAssociationApiError } from './action-association-api.js';
|
|
8
|
+
|
|
9
|
+
export const sessionAssociationContext = (candidate) => {
|
|
10
|
+
const grounded =
|
|
11
|
+
candidate.analysis?.state === 'continue' &&
|
|
12
|
+
candidate.analysis?.basis?.some((item) => item.role === 'user');
|
|
13
|
+
const objective =
|
|
14
|
+
((grounded && candidate.analysis.objective) || candidate.title || '').slice(
|
|
15
|
+
0,
|
|
16
|
+
1000,
|
|
17
|
+
) || 'Selected conversation';
|
|
18
|
+
const context = {
|
|
19
|
+
objective,
|
|
20
|
+
deliverables: grounded
|
|
21
|
+
? (candidate.analysis.remaining || []).slice(0, 8)
|
|
22
|
+
: [],
|
|
23
|
+
projectHints: [],
|
|
24
|
+
repositoryUrls: [],
|
|
25
|
+
relatedUrls: [],
|
|
26
|
+
actionIds: [],
|
|
27
|
+
sourceRefs: [],
|
|
28
|
+
basis: [],
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
...context,
|
|
32
|
+
revision: associationRevision({
|
|
33
|
+
...context,
|
|
34
|
+
provider: candidate.provider,
|
|
35
|
+
session: candidate.session,
|
|
36
|
+
cwd: candidate.cwd,
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
const namespaceFor = (origin, scope) =>
|
|
41
|
+
JSON.stringify([new URL(origin).origin, scope.organizationId, scope.userId]);
|
|
42
|
+
|
|
43
|
+
// This store grants no continuation authority. Only validated discovery metadata
|
|
44
|
+
// can create identities; record locks serialize identity creation and corrections.
|
|
45
|
+
export function createOrdinaryAssociationStore({
|
|
46
|
+
root,
|
|
47
|
+
api,
|
|
48
|
+
webOrigin,
|
|
49
|
+
now = Date.now,
|
|
50
|
+
onChange = () => {},
|
|
51
|
+
}) {
|
|
52
|
+
const inFlight = new Map();
|
|
53
|
+
const views = new Map(),
|
|
54
|
+
abort = new AbortController();
|
|
55
|
+
let serial = Promise.resolve(),
|
|
56
|
+
closed = false;
|
|
57
|
+
const find = async (candidate) =>
|
|
58
|
+
(await listRecords(root)).find(
|
|
59
|
+
(item) =>
|
|
60
|
+
item.provider === candidate.provider &&
|
|
61
|
+
item.session === candidate.session,
|
|
62
|
+
);
|
|
63
|
+
async function ensure(candidate, known) {
|
|
64
|
+
if (
|
|
65
|
+
!['codex', 'claude'].includes(candidate.provider) ||
|
|
66
|
+
!candidate.session ||
|
|
67
|
+
!candidate.cwd
|
|
68
|
+
)
|
|
69
|
+
throw new ActionAssociationApiError('invalid_request');
|
|
70
|
+
const existing = known === undefined ? await find(candidate) : known;
|
|
71
|
+
if (existing?.associationTaskId && existing.cwd === candidate.cwd)
|
|
72
|
+
return existing;
|
|
73
|
+
return updateRecord(
|
|
74
|
+
root,
|
|
75
|
+
candidate.provider,
|
|
76
|
+
candidate.session,
|
|
77
|
+
(previous) => {
|
|
78
|
+
if (previous && previous.cwd !== candidate.cwd)
|
|
79
|
+
throw new ActionAssociationApiError('conflict');
|
|
80
|
+
const record = previous || {
|
|
81
|
+
schemaVersion: 1,
|
|
82
|
+
provider: candidate.provider,
|
|
83
|
+
session: candidate.session,
|
|
84
|
+
cwd: candidate.cwd,
|
|
85
|
+
run: null,
|
|
86
|
+
};
|
|
87
|
+
record.associationTaskId ||=
|
|
88
|
+
record.run?.managed?.taskId || randomUUID();
|
|
89
|
+
return record;
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
async function sync(candidate, force = false, known) {
|
|
94
|
+
const task = candidate.taskId || known?.associationTaskId;
|
|
95
|
+
if (inFlight.has(task)) await inFlight.get(task);
|
|
96
|
+
if (closed) return;
|
|
97
|
+
const record = known || (await ensure(candidate)),
|
|
98
|
+
taskId = record.run?.managed?.taskId || record.associationTaskId;
|
|
99
|
+
candidate.taskId = taskId;
|
|
100
|
+
candidate.binding = { ...candidate.binding, taskId };
|
|
101
|
+
if (record.run?.managed) return;
|
|
102
|
+
const auth = await api.authRevision(),
|
|
103
|
+
context = sessionAssociationContext(candidate);
|
|
104
|
+
let view = views.get(taskId);
|
|
105
|
+
if (!auth) {
|
|
106
|
+
views.set(taskId, { auth, status: 'signed_out' });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
!force &&
|
|
111
|
+
view?.auth === auth &&
|
|
112
|
+
view.contextRevision === context.revision &&
|
|
113
|
+
now() < view.nextRefresh
|
|
114
|
+
)
|
|
115
|
+
return;
|
|
116
|
+
view = {
|
|
117
|
+
auth,
|
|
118
|
+
contextRevision: context.revision,
|
|
119
|
+
status: 'loading',
|
|
120
|
+
nextRefresh: now() + 15000,
|
|
121
|
+
};
|
|
122
|
+
views.set(taskId, view);
|
|
123
|
+
try {
|
|
124
|
+
const snapshot = await api.candidates(
|
|
125
|
+
{ taskId, context, limit: 10 },
|
|
126
|
+
{ signal: abort.signal },
|
|
127
|
+
);
|
|
128
|
+
if (closed || (await api.authRevision()) !== auth)
|
|
129
|
+
throw new ActionAssociationApiError('account_changed');
|
|
130
|
+
if (
|
|
131
|
+
snapshot.taskId !== taskId ||
|
|
132
|
+
snapshot.contextRevision !== context.revision ||
|
|
133
|
+
(snapshot.binding && snapshot.binding.taskId !== taskId)
|
|
134
|
+
)
|
|
135
|
+
throw new ActionAssociationApiError('invalid_response');
|
|
136
|
+
const namespace = namespaceFor(webOrigin, snapshot.scope);
|
|
137
|
+
const persisted = await updateRecord(
|
|
138
|
+
root,
|
|
139
|
+
candidate.provider,
|
|
140
|
+
candidate.session,
|
|
141
|
+
(latest) => {
|
|
142
|
+
if (latest.associationTaskId !== taskId || latest.run?.managed)
|
|
143
|
+
return latest;
|
|
144
|
+
if (
|
|
145
|
+
latest.actionAssociation?.namespace === namespace &&
|
|
146
|
+
(latest.actionAssociation.binding?.revision || 0) >
|
|
147
|
+
(snapshot.binding?.revision || 0)
|
|
148
|
+
)
|
|
149
|
+
return latest;
|
|
150
|
+
latest.actionAssociation = {
|
|
151
|
+
namespace,
|
|
152
|
+
authRevision: auth,
|
|
153
|
+
binding: snapshot.binding || null,
|
|
154
|
+
contextRevision: context.revision,
|
|
155
|
+
status: snapshot.binding?.action
|
|
156
|
+
? 'bound'
|
|
157
|
+
: snapshot.binding
|
|
158
|
+
? 'unlinked'
|
|
159
|
+
: 'no_match',
|
|
160
|
+
};
|
|
161
|
+
return latest;
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
if (
|
|
165
|
+
persisted?.actionAssociation?.namespace === namespace &&
|
|
166
|
+
(persisted.actionAssociation.binding?.revision || 0) >
|
|
167
|
+
(snapshot.binding?.revision || 0)
|
|
168
|
+
)
|
|
169
|
+
snapshot.binding = persisted.actionAssociation.binding;
|
|
170
|
+
Object.assign(view, {
|
|
171
|
+
namespace,
|
|
172
|
+
snapshot,
|
|
173
|
+
status: snapshot.binding?.action
|
|
174
|
+
? 'bound'
|
|
175
|
+
: snapshot.binding
|
|
176
|
+
? 'unlinked'
|
|
177
|
+
: 'no_match',
|
|
178
|
+
});
|
|
179
|
+
} catch (error) {
|
|
180
|
+
view.status = error.code || 'offline';
|
|
181
|
+
view.snapshot = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
async decorate(sessions) {
|
|
186
|
+
const records = new Map(
|
|
187
|
+
(await listRecords(root)).map((record) => [
|
|
188
|
+
`${record.provider}:${record.session}`,
|
|
189
|
+
record,
|
|
190
|
+
]),
|
|
191
|
+
);
|
|
192
|
+
const auth = await api.authRevision();
|
|
193
|
+
for (const candidate of sessions) {
|
|
194
|
+
const record = await ensure(
|
|
195
|
+
candidate,
|
|
196
|
+
records.get(`${candidate.provider}:${candidate.session}`) || null,
|
|
197
|
+
),
|
|
198
|
+
taskId = record.run?.managed?.taskId || record.associationTaskId;
|
|
199
|
+
candidate.taskId = taskId;
|
|
200
|
+
candidate.binding = { ...candidate.binding, taskId };
|
|
201
|
+
if (record.run?.managed) continue;
|
|
202
|
+
const cached = views.get(taskId),
|
|
203
|
+
context = sessionAssociationContext(candidate);
|
|
204
|
+
if (
|
|
205
|
+
auth &&
|
|
206
|
+
!closed &&
|
|
207
|
+
inFlight.size < 3 &&
|
|
208
|
+
!inFlight.has(taskId) &&
|
|
209
|
+
(!cached ||
|
|
210
|
+
cached.auth !== auth ||
|
|
211
|
+
cached.contextRevision !== context.revision ||
|
|
212
|
+
now() >= cached.nextRefresh)
|
|
213
|
+
) {
|
|
214
|
+
const work = sync(candidate, false, record)
|
|
215
|
+
.catch(() => {})
|
|
216
|
+
.finally(() => {
|
|
217
|
+
inFlight.delete(taskId);
|
|
218
|
+
if (!closed) onChange();
|
|
219
|
+
});
|
|
220
|
+
inFlight.set(taskId, work);
|
|
221
|
+
}
|
|
222
|
+
const view = views.get(taskId);
|
|
223
|
+
const snapshot = view?.auth === auth && auth ? view.snapshot : null;
|
|
224
|
+
const binding = snapshot?.binding;
|
|
225
|
+
candidate.actionAssociation = {
|
|
226
|
+
status: !auth ? 'signed_out' : view?.status || 'loading',
|
|
227
|
+
binding: binding
|
|
228
|
+
? {
|
|
229
|
+
...binding,
|
|
230
|
+
evidence: [],
|
|
231
|
+
reason: (binding.reason || '').slice(0, 60),
|
|
232
|
+
coveredWork: (binding.coveredWork || '').slice(0, 60),
|
|
233
|
+
action: binding.action
|
|
234
|
+
? {
|
|
235
|
+
...binding.action,
|
|
236
|
+
title: (binding.action.title || '').slice(0, 120),
|
|
237
|
+
description: null,
|
|
238
|
+
}
|
|
239
|
+
: null,
|
|
240
|
+
}
|
|
241
|
+
: null,
|
|
242
|
+
candidates: (snapshot?.candidates || []).slice(0, 5).map((item) => ({
|
|
243
|
+
id: item.id,
|
|
244
|
+
revision: item.revision,
|
|
245
|
+
title: item.title.slice(0, 120),
|
|
246
|
+
})),
|
|
247
|
+
canCorrect: Boolean(snapshot),
|
|
248
|
+
contextRevision: view?.contextRevision || null,
|
|
249
|
+
candidateRevision: snapshot?.candidateRevision || null,
|
|
250
|
+
truncated: (snapshot?.candidates?.length || 0) > 5,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return sessions;
|
|
254
|
+
},
|
|
255
|
+
correct(command, candidate) {
|
|
256
|
+
const work = serial.then(async () => {
|
|
257
|
+
const before = await find(candidate);
|
|
258
|
+
if (
|
|
259
|
+
!before ||
|
|
260
|
+
before.associationTaskId !== command.taskId ||
|
|
261
|
+
before.run?.managed
|
|
262
|
+
)
|
|
263
|
+
throw new ActionAssociationApiError('invalid_request');
|
|
264
|
+
const auth = await api.authRevision();
|
|
265
|
+
if (!auth) throw new ActionAssociationApiError('signed_out');
|
|
266
|
+
await sync(candidate, true);
|
|
267
|
+
const view = views.get(command.taskId);
|
|
268
|
+
if (
|
|
269
|
+
!view?.snapshot ||
|
|
270
|
+
view.auth !== auth ||
|
|
271
|
+
(await api.authRevision()) !== auth
|
|
272
|
+
)
|
|
273
|
+
throw new ActionAssociationApiError(
|
|
274
|
+
view?.status || 'account_changed',
|
|
275
|
+
);
|
|
276
|
+
if (command.action === 'session_action_refresh') return;
|
|
277
|
+
if (
|
|
278
|
+
(command.expectedContextRevision || command.contextRevision) &&
|
|
279
|
+
(command.expectedContextRevision || command.contextRevision) !==
|
|
280
|
+
view.contextRevision
|
|
281
|
+
)
|
|
282
|
+
throw new ActionAssociationApiError('conflict');
|
|
283
|
+
const snapshot = view.snapshot,
|
|
284
|
+
selected =
|
|
285
|
+
command.action === 'session_action_select'
|
|
286
|
+
? snapshot.candidates.find((item) => item.id === command.actionId)
|
|
287
|
+
: null;
|
|
288
|
+
if (command.action !== 'session_action_unlink' && !selected)
|
|
289
|
+
throw new ActionAssociationApiError('invalid_request');
|
|
290
|
+
if (
|
|
291
|
+
(command.expectedCandidateRevision &&
|
|
292
|
+
command.expectedCandidateRevision !== snapshot.candidateRevision) ||
|
|
293
|
+
(selected &&
|
|
294
|
+
command.expectedActionRevision &&
|
|
295
|
+
command.expectedActionRevision !== selected.revision)
|
|
296
|
+
)
|
|
297
|
+
throw new ActionAssociationApiError('conflict');
|
|
298
|
+
const binding = snapshot.binding;
|
|
299
|
+
// Retransmitting the same click is successful without a second write.
|
|
300
|
+
if (
|
|
301
|
+
binding?.origin === 'user' &&
|
|
302
|
+
(binding.action?.id || null) === (selected?.id || null)
|
|
303
|
+
)
|
|
304
|
+
return;
|
|
305
|
+
if ((binding?.revision ?? null) !== command.expectedBindingRevision)
|
|
306
|
+
throw new ActionAssociationApiError('conflict');
|
|
307
|
+
try {
|
|
308
|
+
const saved = await api.saveBinding(
|
|
309
|
+
command.taskId,
|
|
310
|
+
{
|
|
311
|
+
expectedRevision: command.expectedBindingRevision,
|
|
312
|
+
contextRevision: view.contextRevision,
|
|
313
|
+
candidateRevision: snapshot.candidateRevision,
|
|
314
|
+
origin: 'user',
|
|
315
|
+
actionId: selected?.id || null,
|
|
316
|
+
actionRevision: selected?.revision || null,
|
|
317
|
+
relation: selected ? 'part_of' : null,
|
|
318
|
+
coveredWork: sessionAssociationContext(candidate).objective,
|
|
319
|
+
reason: selected
|
|
320
|
+
? 'Selected by the user.'
|
|
321
|
+
: 'Unlinked by the user.',
|
|
322
|
+
evidence: [],
|
|
323
|
+
},
|
|
324
|
+
{ signal: abort.signal },
|
|
325
|
+
);
|
|
326
|
+
if (
|
|
327
|
+
(await api.authRevision()) !== auth ||
|
|
328
|
+
namespaceFor(webOrigin, saved.scope) !== view.namespace ||
|
|
329
|
+
saved.binding?.taskId !== command.taskId
|
|
330
|
+
)
|
|
331
|
+
throw new ActionAssociationApiError('account_changed');
|
|
332
|
+
await updateRecord(
|
|
333
|
+
root,
|
|
334
|
+
candidate.provider,
|
|
335
|
+
candidate.session,
|
|
336
|
+
(latest) => {
|
|
337
|
+
if (
|
|
338
|
+
latest.associationTaskId !== command.taskId ||
|
|
339
|
+
latest.run?.managed
|
|
340
|
+
)
|
|
341
|
+
throw new ActionAssociationApiError('conflict');
|
|
342
|
+
latest.actionAssociation = {
|
|
343
|
+
namespace: view.namespace,
|
|
344
|
+
authRevision: auth,
|
|
345
|
+
binding: saved.binding,
|
|
346
|
+
contextRevision: view.contextRevision,
|
|
347
|
+
status: saved.binding.action ? 'bound' : 'unlinked',
|
|
348
|
+
};
|
|
349
|
+
return latest;
|
|
350
|
+
},
|
|
351
|
+
);
|
|
352
|
+
view.nextRefresh = 0;
|
|
353
|
+
} catch (error) {
|
|
354
|
+
view.nextRefresh = 0;
|
|
355
|
+
if (error.code === 'conflict') await sync(candidate, true);
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
serial = work.catch(() => {});
|
|
360
|
+
return work;
|
|
361
|
+
},
|
|
362
|
+
async close() {
|
|
363
|
+
closed = true;
|
|
364
|
+
abort.abort();
|
|
365
|
+
await Promise.allSettled([serial, ...inFlight.values()]);
|
|
366
|
+
views.clear();
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|