@awak-app/simy-cli 0.5.43 → 0.5.45
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 +64 -0
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +14 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +4 -0
- package/src/codex-plugins/simy/hooks/dev-work.mjs +270 -0
- package/src/codex-plugins/simy/hooks/hooks.json +9 -0
- package/src/desktop-host.js +26 -3
- package/src/dev-work-events.js +103 -0
- 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/index.js +2 -1
- package/src/sqm/claude-plugin.js +1 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createOrdinaryAssociationStore } from './association-store.js';
|
|
1
2
|
import { listRecords, updateRecord, TERMINAL, managedBudgetReason } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
2
3
|
import { createActionAssociationApi, ActionAssociationApiError } from './action-association-api.js';
|
|
3
4
|
import { actionCandidateRequest, prepareActionAssociation, importActionBinding,
|
|
@@ -14,8 +15,9 @@ const hardFailure = code => ['unauthorized', 'unavailable', 'invalid_request', '
|
|
|
14
15
|
// Association is optional metadata. Network failures never control the task's
|
|
15
16
|
// work state; provider turns and their budgets remain owned by the supervisor.
|
|
16
17
|
export function createActionAssociationHost({ root, sessionRoot, webOrigin, api = createActionAssociationApi({ webOrigin, sessionRoot }),
|
|
17
|
-
now = Date.now, refreshMs = 60_000, list = listRecords } = {}) {
|
|
18
|
+
now = Date.now, refreshMs = 60_000, list = listRecords, onChange = () => {} } = {}) {
|
|
18
19
|
const abort = new AbortController(), views = new Map();
|
|
20
|
+
const ordinary = createOrdinaryAssociationStore({ root, api, webOrigin, now, onChange });
|
|
19
21
|
let auth = null, closed = false, running = null, serial = Promise.resolve();
|
|
20
22
|
const enqueue = work => {
|
|
21
23
|
const result = serial.then(work); serial = result.catch(() => {}); return result;
|
|
@@ -144,6 +146,34 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
144
146
|
}));
|
|
145
147
|
}
|
|
146
148
|
return {
|
|
149
|
+
async decorateSessions(sessions) {
|
|
150
|
+
await ordinary.decorate(sessions);
|
|
151
|
+
const records = await list(root);
|
|
152
|
+
const status = { runs: sessions.filter(item => records.some(record => record.provider === item.provider && record.session === item.session && record.run?.managed)).map(item => {
|
|
153
|
+
const record = records.find(record => record.provider === item.provider && record.session === item.session);
|
|
154
|
+
return { managed: true, taskId: record.run.managed.taskId, actionAssociation: record.run.managed.actionAssociation, candidate: item };
|
|
155
|
+
}) };
|
|
156
|
+
await this.decorate(status);
|
|
157
|
+
for (const run of status.runs) run.candidate.actionAssociation = run.actionAssociation;
|
|
158
|
+
return sessions;
|
|
159
|
+
},
|
|
160
|
+
async deadlineContext(candidate) {
|
|
161
|
+
const expected = await current(), record = (await list(root)).find(item => item.provider === candidate.provider && item.session === candidate.session);
|
|
162
|
+
const local = record?.run?.managed?.actionAssociation || record?.actionAssociation;
|
|
163
|
+
if (!expected || local?.authRevision !== expected || !candidate.actionAssociation?.canCorrect) return null;
|
|
164
|
+
const binding = candidate.actionAssociation.binding;
|
|
165
|
+
const fingerprint = value => JSON.stringify([value?.revision, value?.action?.id, value?.action?.revision, value?.action?.dueAt, value?.action?.timeZone, value?.action?.status]);
|
|
166
|
+
return { namespace: local.namespace, provider: candidate.provider, session: candidate.session, taskId: candidate.taskId, binding,
|
|
167
|
+
taskStatus: null, remainingWork: record.run?.managed?.sessionUnderstanding?.remaining || record.run?.managed?.remainingWork || candidate.analysis?.remaining || candidate.analysis?.remainingWork || null,
|
|
168
|
+
plan: record.run?.managed?.plan || candidate.analysis?.plan || null, blocker: record.run?.blocker || candidate.analysis?.blocker || null, online: true,
|
|
169
|
+
validate: async () => {
|
|
170
|
+
if (!await valid(expected)) return false;
|
|
171
|
+
const latest = (await list(root)).find(item => item.provider === candidate.provider && item.session === candidate.session);
|
|
172
|
+
const scoped = latest?.run?.managed?.actionAssociation || latest?.actionAssociation;
|
|
173
|
+
return (latest?.run?.managed?.taskId || latest?.associationTaskId) === candidate.taskId && scoped?.namespace === local.namespace && scoped?.authRevision === expected && fingerprint(scoped.binding) === fingerprint(binding);
|
|
174
|
+
} };
|
|
175
|
+
|
|
176
|
+
},
|
|
147
177
|
async prepareCached(record) {
|
|
148
178
|
const view = views.get(record?.run?.managed?.taskId), request = actionCandidateRequest(record);
|
|
149
179
|
if (closed || !auth || !view || view.auth !== auth || view.error || !view.snapshot || !request || view.contextRevision !== request.context.revision || view.snapshot.contextRevision !== request.context.revision || view.namespace !== namespaceFor(webOrigin, view.snapshot.scope)) return record;
|
|
@@ -170,7 +200,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
170
200
|
status: view.error || local?.status || 'waiting_boundary',
|
|
171
201
|
binding: view.namespace && local?.namespace === view.namespace ? publicBinding(local.binding) : null,
|
|
172
202
|
candidates: (view.candidates || []).slice(0,5), canCorrect: !view.error,
|
|
173
|
-
retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
|
|
203
|
+
contextRevision: view.contextRevision, candidateRevision: view.snapshot?.candidateRevision || null, retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
|
|
174
204
|
};
|
|
175
205
|
let bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation));
|
|
176
206
|
if (bytes > Math.min(remainingBytes,perRunBytes)) { run.actionAssociation.candidates = []; run.actionAssociation.truncated = true; bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation)); }
|
|
@@ -183,7 +213,8 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
183
213
|
}
|
|
184
214
|
return status;
|
|
185
215
|
},
|
|
186
|
-
correct(command) {
|
|
216
|
+
correct(command, candidate) {
|
|
217
|
+
if (candidate && candidate.binding?.mode !== 'managed' && candidate.binding?.mode !== 'pending_takeover') return ordinary.correct(command, candidate);
|
|
187
218
|
return enqueue(async () => {
|
|
188
219
|
const expected = await current(), record = await fresh(command);
|
|
189
220
|
if (!expected) throw new ActionAssociationApiError('signed_out');
|
|
@@ -192,9 +223,11 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
192
223
|
const view = views.get(command.taskId);
|
|
193
224
|
if (!await valid(expected) || !view?.namespace || view.error) throw new ActionAssociationApiError(view?.error || 'unavailable');
|
|
194
225
|
if (command.action === 'session_action_refresh') { view.nextRefresh = 0; return; }
|
|
226
|
+
if (command.expectedContextRevision && command.expectedContextRevision !== view.contextRevision || command.expectedCandidateRevision && command.expectedCandidateRevision !== view.snapshot?.candidateRevision) throw new ActionAssociationApiError('conflict');
|
|
195
227
|
const latest = await fresh(command), binding = latest?.run.managed.actionAssociation?.binding;
|
|
196
228
|
if ((binding?.revision ?? null) !== command.expectedBindingRevision) throw new ActionAssociationApiError('conflict');
|
|
197
229
|
const selected = command.action === 'session_action_select' ? view.snapshot?.candidates.find(item => item.id === command.actionId) || (!view.snapshot && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(command.actionId || '') ? { id: command.actionId, revision: null } : null) : null;
|
|
230
|
+
if (selected && command.expectedActionRevision && command.expectedActionRevision !== selected.revision) throw new ActionAssociationApiError('conflict');
|
|
198
231
|
if (command.action !== 'session_action_unlink' && !selected) throw new ActionAssociationApiError('invalid_request');
|
|
199
232
|
const envelope = await api.saveBinding(command.taskId, {
|
|
200
233
|
expectedRevision: command.expectedBindingRevision, contextRevision: view.contextRevision,
|
|
@@ -208,6 +241,6 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
208
241
|
view.error = null; view.nextRefresh = 0;
|
|
209
242
|
});
|
|
210
243
|
},
|
|
211
|
-
async close() { closed = true; abort.abort(); await Promise.allSettled([serial, running]); views.clear(); },
|
|
244
|
+
async close() { closed = true; abort.abort(); await Promise.allSettled([serial, running, ordinary.close()]); views.clear(); },
|
|
212
245
|
};
|
|
213
246
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
// All instances in a host share a queue; every transaction reloads disk so a
|
|
6
|
+
// newly created host sees durable decisions instead of an in-memory snapshot.
|
|
7
|
+
const queues = new Map();
|
|
8
|
+
export function createActionDeadlineStore({ root }) {
|
|
9
|
+
const directory = join(root, "action-deadlines");
|
|
10
|
+
const pathFor = (identity) =>
|
|
11
|
+
join(
|
|
12
|
+
directory,
|
|
13
|
+
`${createHash("sha256").update(JSON.stringify(identity)).digest("hex")}.json`,
|
|
14
|
+
);
|
|
15
|
+
const read = async (identity) => {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(await readFile(pathFor(identity), "utf8"));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code === "ENOENT") return null;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
read,
|
|
25
|
+
transact(identity, update, validate = async () => true) {
|
|
26
|
+
const path = pathFor(identity);
|
|
27
|
+
const result = (queues.get(path) || Promise.resolve()).then(async () => {
|
|
28
|
+
const previous = await read(identity);
|
|
29
|
+
const assertCurrent = async () => {
|
|
30
|
+
if (!(await validate()))
|
|
31
|
+
throw Object.assign(new Error("Deadline context changed."), {
|
|
32
|
+
code: "conflict",
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
await assertCurrent();
|
|
36
|
+
const next = await update(previous);
|
|
37
|
+
if (next !== previous && next !== undefined) {
|
|
38
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
39
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
40
|
+
await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
|
|
41
|
+
try {
|
|
42
|
+
await assertCurrent();
|
|
43
|
+
await rename(temporary, path);
|
|
44
|
+
} finally {
|
|
45
|
+
await rm(temporary, { force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return next === undefined ? previous : next;
|
|
49
|
+
});
|
|
50
|
+
queues.set(
|
|
51
|
+
path,
|
|
52
|
+
result.catch(() => {}),
|
|
53
|
+
);
|
|
54
|
+
return result;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -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
|
+
}
|