@reconcrap/boss-recommend-mcp 2.1.23 → 2.1.25
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 +5 -0
- package/bin/boss-recommend-mcp.js +4 -4
- package/package.json +6 -1
- package/scripts/install-macos.sh +280 -280
- package/scripts/postinstall.cjs +44 -44
- package/skills/boss-chat/README.md +43 -42
- package/skills/boss-chat/SKILL.md +107 -106
- package/skills/boss-recommend-pipeline/README.md +13 -13
- package/skills/boss-recommend-pipeline/SKILL.md +47 -47
- package/skills/boss-recruit-pipeline/README.md +19 -19
- package/skills/boss-recruit-pipeline/SKILL.md +89 -89
- package/src/chat-mcp.js +301 -127
- package/src/chat-runtime-config.js +7 -5
- package/src/core/boss-cards/index.js +199 -199
- package/src/core/browser/index.js +302 -145
- package/src/core/capture/index.js +2930 -1201
- package/src/core/cv-acquisition/index.js +512 -238
- package/src/core/cv-capture-target/index.js +513 -299
- package/src/core/greet-quota/index.js +71 -71
- package/src/core/infinite-list/index.js +31 -31
- package/src/core/reporting/legacy-csv.js +12 -12
- package/src/core/run/detached-launcher.js +305 -0
- package/src/core/run/index.js +109 -55
- package/src/core/run/timing.js +33 -33
- package/src/core/run/windows-detached-worker.ps1 +106 -0
- package/src/core/screening/index.js +2400 -2135
- package/src/core/self-heal/index.js +989 -973
- package/src/core/self-heal/viewport.js +1505 -564
- package/src/detached-worker.js +99 -99
- package/src/domains/chat/action-journal.js +536 -0
- package/src/domains/chat/cards.js +137 -137
- package/src/domains/chat/constants.js +9 -9
- package/src/domains/chat/detail.js +1684 -401
- package/src/domains/chat/index.js +8 -7
- package/src/domains/chat/jobs.js +620 -620
- package/src/domains/chat/page-guard.js +157 -122
- package/src/domains/chat/roots.js +56 -56
- package/src/domains/chat/run-service.js +1801 -760
- package/src/domains/common/account-rights-panel.js +314 -314
- package/src/domains/common/recovery-settle.js +159 -159
- package/src/domains/recommend/actions.js +1219 -472
- package/src/domains/recommend/cards.js +243 -243
- package/src/domains/recommend/colleague-contact.js +1079 -333
- package/src/domains/recommend/constants.js +92 -92
- package/src/domains/recommend/detail.js +4037 -136
- package/src/domains/recommend/filters.js +608 -590
- package/src/domains/recommend/index.js +9 -9
- package/src/domains/recommend/jobs.js +571 -542
- package/src/domains/recommend/location.js +754 -707
- package/src/domains/recommend/refresh.js +677 -392
- package/src/domains/recommend/roots.js +80 -80
- package/src/domains/recommend/run-service.js +4048 -1447
- package/src/domains/recommend/scopes.js +246 -246
- package/src/domains/recruit/actions.js +277 -277
- package/src/domains/recruit/cards.js +74 -74
- package/src/domains/recruit/constants.js +236 -236
- package/src/domains/recruit/detail.js +588 -588
- package/src/domains/recruit/index.js +9 -9
- package/src/domains/recruit/instruction-parser.js +866 -866
- package/src/domains/recruit/refresh.js +45 -45
- package/src/domains/recruit/roots.js +68 -68
- package/src/domains/recruit/run-service.js +1817 -1620
- package/src/domains/recruit/search.js +3229 -3229
- package/src/index.js +16 -1
- package/src/parser.js +1296 -1296
- package/src/recommend-mcp.js +1061 -450
- package/src/recommend-scheduler.js +75 -75
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export const CHAT_ACTION_JOURNAL_SCHEMA_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
export const CHAT_ACTION_STATES = Object.freeze([
|
|
9
|
+
"pre_action",
|
|
10
|
+
"greeting_send_in_flight",
|
|
11
|
+
"greeting_confirmed",
|
|
12
|
+
"request_in_flight",
|
|
13
|
+
"request_confirmed",
|
|
14
|
+
"outcome_unknown"
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const CHAT_ACTION_STATE_SET = new Set(CHAT_ACTION_STATES);
|
|
18
|
+
const INITIAL_STATE = "pre_action";
|
|
19
|
+
const ALLOWED_TRANSITIONS = Object.freeze({
|
|
20
|
+
pre_action: new Set(["greeting_send_in_flight"]),
|
|
21
|
+
greeting_send_in_flight: new Set(["greeting_confirmed", "outcome_unknown"]),
|
|
22
|
+
greeting_confirmed: new Set(["request_in_flight"]),
|
|
23
|
+
request_in_flight: new Set(["request_confirmed", "outcome_unknown"]),
|
|
24
|
+
request_confirmed: new Set(),
|
|
25
|
+
outcome_unknown: new Set(["greeting_confirmed", "request_confirmed"])
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function normalizeText(value) {
|
|
29
|
+
return String(value ?? "").trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeGreeting(value) {
|
|
33
|
+
return String(value ?? "").replace(/\s+/gu, " ").trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sha256(value) {
|
|
37
|
+
return crypto.createHash("sha256").update(String(value), "utf8").digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function journalError(code, message, details = {}) {
|
|
41
|
+
const error = new Error(message);
|
|
42
|
+
error.code = code;
|
|
43
|
+
Object.assign(error, details);
|
|
44
|
+
return error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function requireScope(scope) {
|
|
48
|
+
const normalized = normalizeText(scope);
|
|
49
|
+
if (!normalized) {
|
|
50
|
+
throw journalError(
|
|
51
|
+
"CHAT_ACTION_SCOPE_REQUIRED",
|
|
52
|
+
"A stable Boss chat account/profile scope is required for the action journal."
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return normalized;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requireCandidateId(candidateId) {
|
|
59
|
+
const normalized = normalizeText(candidateId);
|
|
60
|
+
if (!normalized) {
|
|
61
|
+
throw journalError(
|
|
62
|
+
"CHAT_ACTION_CANDIDATE_ID_REQUIRED",
|
|
63
|
+
"A stable Boss candidate ID is required before an outbound chat action can be journaled."
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return normalized;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function requireState(state) {
|
|
70
|
+
const normalized = normalizeText(state);
|
|
71
|
+
if (!CHAT_ACTION_STATE_SET.has(normalized)) {
|
|
72
|
+
throw journalError(
|
|
73
|
+
"CHAT_ACTION_STATE_INVALID",
|
|
74
|
+
`Unsupported Boss chat action journal state: ${normalized || "<empty>"}.`,
|
|
75
|
+
{ state: normalized || null }
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return normalized;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function defaultBaseDir() {
|
|
82
|
+
const configuredHome = normalizeText(process.env.BOSS_CHAT_HOME);
|
|
83
|
+
const chatHome = configuredHome || path.join(os.homedir(), ".boss-recommend-mcp", "boss-chat");
|
|
84
|
+
return path.join(chatHome, "action-journal");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function nowIso(now) {
|
|
88
|
+
const raw = now();
|
|
89
|
+
const date = raw instanceof Date ? raw : new Date(raw);
|
|
90
|
+
if (Number.isNaN(date.getTime())) {
|
|
91
|
+
throw journalError(
|
|
92
|
+
"CHAT_ACTION_JOURNAL_CLOCK_INVALID",
|
|
93
|
+
`Boss chat action journal clock returned an invalid value: ${String(raw)}.`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return date.toISOString();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function cloneRecord(record) {
|
|
100
|
+
return record == null ? null : JSON.parse(JSON.stringify(record));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readJsonRecord(filePath) {
|
|
104
|
+
if (!fs.existsSync(filePath)) return null;
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
107
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
108
|
+
throw new Error("journal payload is not an object");
|
|
109
|
+
}
|
|
110
|
+
return parsed;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw journalError(
|
|
113
|
+
"CHAT_ACTION_JOURNAL_CORRUPT",
|
|
114
|
+
`Unable to read Boss chat action journal record: ${error?.message || error}.`,
|
|
115
|
+
{ file_path: filePath }
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function writeJsonAtomic(filePath, payload) {
|
|
121
|
+
const directory = path.dirname(filePath);
|
|
122
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
123
|
+
const tempPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
124
|
+
let fileHandle = null;
|
|
125
|
+
try {
|
|
126
|
+
fileHandle = fs.openSync(tempPath, "wx", 0o600);
|
|
127
|
+
fs.writeFileSync(fileHandle, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
128
|
+
fs.fsyncSync(fileHandle);
|
|
129
|
+
fs.closeSync(fileHandle);
|
|
130
|
+
fileHandle = null;
|
|
131
|
+
fs.renameSync(tempPath, filePath);
|
|
132
|
+
|
|
133
|
+
// Directory fsync is unsupported on some Windows/filesystem combinations.
|
|
134
|
+
// The record itself has already been atomically replaced, so this is best-effort.
|
|
135
|
+
let directoryHandle = null;
|
|
136
|
+
try {
|
|
137
|
+
directoryHandle = fs.openSync(directory, "r");
|
|
138
|
+
fs.fsyncSync(directoryHandle);
|
|
139
|
+
} catch {
|
|
140
|
+
// Keep the portable temp+rename guarantee when directory fsync is unavailable.
|
|
141
|
+
} finally {
|
|
142
|
+
if (directoryHandle != null) fs.closeSync(directoryHandle);
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
if (fileHandle != null) fs.closeSync(fileHandle);
|
|
146
|
+
try {
|
|
147
|
+
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
148
|
+
} catch {
|
|
149
|
+
// A leftover temp file is never treated as a committed journal record.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function acquireRecordLock(lockPath) {
|
|
155
|
+
let handle = null;
|
|
156
|
+
try {
|
|
157
|
+
handle = fs.openSync(lockPath, "wx", 0o600);
|
|
158
|
+
fs.writeFileSync(handle, `${process.pid}\n`, "utf8");
|
|
159
|
+
fs.fsyncSync(handle);
|
|
160
|
+
return handle;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (handle != null) {
|
|
163
|
+
try {
|
|
164
|
+
fs.closeSync(handle);
|
|
165
|
+
} catch {}
|
|
166
|
+
try {
|
|
167
|
+
fs.unlinkSync(lockPath);
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
if (error?.code === "EEXIST") {
|
|
171
|
+
throw journalError(
|
|
172
|
+
"CHAT_ACTION_JOURNAL_BUSY",
|
|
173
|
+
"The Boss chat action journal record is locked by another writer; outbound action must fail closed.",
|
|
174
|
+
{ lock_path: lockPath }
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function releaseRecordLock(lockPath, handle) {
|
|
182
|
+
try {
|
|
183
|
+
if (handle != null) fs.closeSync(handle);
|
|
184
|
+
} finally {
|
|
185
|
+
try {
|
|
186
|
+
fs.unlinkSync(lockPath);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error?.code !== "ENOENT") throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function outcomeUnknownOrigin(record) {
|
|
194
|
+
const history = Array.isArray(record?.history) ? record.history : [];
|
|
195
|
+
const last = history[history.length - 1];
|
|
196
|
+
return last?.state === "outcome_unknown" ? last.from_state : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isAllowedTransition(record, nextState) {
|
|
200
|
+
if (!record) return nextState === INITIAL_STATE;
|
|
201
|
+
if (record.state === nextState) return true;
|
|
202
|
+
if (record.state === "outcome_unknown") {
|
|
203
|
+
const origin = outcomeUnknownOrigin(record);
|
|
204
|
+
return (
|
|
205
|
+
(origin === "greeting_send_in_flight" && nextState === "greeting_confirmed")
|
|
206
|
+
|| (origin === "request_in_flight" && nextState === "request_confirmed")
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return ALLOWED_TRANSITIONS[record.state]?.has(nextState) === true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function validateStoredRecord(record, identity, filePath) {
|
|
213
|
+
const valid = (
|
|
214
|
+
record.schema_version === CHAT_ACTION_JOURNAL_SCHEMA_VERSION
|
|
215
|
+
&& record.action_key === identity.actionKey
|
|
216
|
+
&& record.scope_sha256 === identity.scopeSha256
|
|
217
|
+
&& record.candidate_id === identity.candidateId
|
|
218
|
+
&& CHAT_ACTION_STATE_SET.has(record.state)
|
|
219
|
+
&& typeof record.greeting_sha256 === "string"
|
|
220
|
+
&& record.greeting_sha256.length === 64
|
|
221
|
+
&& Array.isArray(record.history)
|
|
222
|
+
);
|
|
223
|
+
if (!valid) {
|
|
224
|
+
throw journalError(
|
|
225
|
+
"CHAT_ACTION_JOURNAL_IDENTITY_MISMATCH",
|
|
226
|
+
"Boss chat action journal record identity or schema does not match the requested candidate.",
|
|
227
|
+
{ file_path: filePath }
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return record;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function appendRunId(runIds, runId) {
|
|
234
|
+
const normalized = normalizeText(runId);
|
|
235
|
+
const current = Array.isArray(runIds) ? runIds.filter(Boolean) : [];
|
|
236
|
+
if (!normalized || current.includes(normalized)) return current;
|
|
237
|
+
return [...current, normalized];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const EVIDENCE_KEYS = new Set([
|
|
241
|
+
"action",
|
|
242
|
+
"action_hit_test_attempt_count",
|
|
243
|
+
"action_hit_test_last_hit_backend_node_id",
|
|
244
|
+
"action_hit_test_reason",
|
|
245
|
+
"active_candidate_id",
|
|
246
|
+
"ask_error",
|
|
247
|
+
"ask_ok",
|
|
248
|
+
"confirm_confirmed",
|
|
249
|
+
"confirm_error",
|
|
250
|
+
"control_backend_node_id",
|
|
251
|
+
"control_center_x",
|
|
252
|
+
"control_center_y",
|
|
253
|
+
"control_label",
|
|
254
|
+
"control_node_id",
|
|
255
|
+
"control_root_backend_node_id",
|
|
256
|
+
"control_root_node_id",
|
|
257
|
+
"control_rect_height",
|
|
258
|
+
"control_rect_width",
|
|
259
|
+
"control_rect_x",
|
|
260
|
+
"control_rect_y",
|
|
261
|
+
"control_root",
|
|
262
|
+
"greeting_baseline_count",
|
|
263
|
+
"greeting_evidence_readable",
|
|
264
|
+
"message_observed",
|
|
265
|
+
"operation_id",
|
|
266
|
+
"pre_input_cdp_method",
|
|
267
|
+
"request_confirmation_source",
|
|
268
|
+
"request_ready_state_observed",
|
|
269
|
+
"reason",
|
|
270
|
+
"request_after_count",
|
|
271
|
+
"request_baseline_count",
|
|
272
|
+
"resume_attachment_after_count",
|
|
273
|
+
"resume_attachment_baseline_count",
|
|
274
|
+
"send_method"
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
function sanitizeEvidence(value = {}) {
|
|
278
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
279
|
+
const result = {};
|
|
280
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
281
|
+
if (!EVIDENCE_KEYS.has(key)) continue;
|
|
282
|
+
if (typeof raw === "boolean" || typeof raw === "number" || raw === null) {
|
|
283
|
+
result[key] = raw;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (typeof raw === "string") result[key] = raw.slice(0, 240);
|
|
287
|
+
}
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function recordRevision(record) {
|
|
292
|
+
if (!record) return 0;
|
|
293
|
+
const historyLength = Array.isArray(record.history) ? record.history.length : 0;
|
|
294
|
+
const storedRevision = record.revision;
|
|
295
|
+
if (storedRevision == null) return historyLength;
|
|
296
|
+
const normalizedRevision = Number(storedRevision);
|
|
297
|
+
if (
|
|
298
|
+
!Number.isSafeInteger(normalizedRevision)
|
|
299
|
+
|| normalizedRevision < 1
|
|
300
|
+
|| normalizedRevision !== historyLength
|
|
301
|
+
) {
|
|
302
|
+
throw journalError(
|
|
303
|
+
"CHAT_ACTION_JOURNAL_CORRUPT",
|
|
304
|
+
"Boss chat action journal revision does not exactly match its append-only history.",
|
|
305
|
+
{
|
|
306
|
+
stored_revision: storedRevision,
|
|
307
|
+
history_length: historyLength
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return normalizedRevision;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function withRecordRevision(record) {
|
|
315
|
+
if (!record) return null;
|
|
316
|
+
return {
|
|
317
|
+
...record,
|
|
318
|
+
revision: recordRevision(record)
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function hashChatActionGreeting(greeting) {
|
|
323
|
+
const normalized = normalizeGreeting(greeting);
|
|
324
|
+
if (!normalized) {
|
|
325
|
+
throw journalError(
|
|
326
|
+
"CHAT_ACTION_GREETING_REQUIRED",
|
|
327
|
+
"A non-empty greeting is required to initialize a Boss chat outbound action journal record."
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
return sha256(`boss-chat-greeting-v1\u0000${normalized}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function createChatActionIdentity({ scope, candidateId } = {}) {
|
|
334
|
+
const normalizedScope = requireScope(scope);
|
|
335
|
+
const normalizedCandidateId = requireCandidateId(candidateId);
|
|
336
|
+
const scopeSha256 = sha256(`boss-chat-scope-v1\u0000${normalizedScope}`);
|
|
337
|
+
const actionKey = sha256(
|
|
338
|
+
`boss-chat-request-cv-v1\u0000${normalizedScope}\u0000${normalizedCandidateId}`
|
|
339
|
+
);
|
|
340
|
+
return {
|
|
341
|
+
actionKey,
|
|
342
|
+
scopeSha256,
|
|
343
|
+
candidateId: normalizedCandidateId
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function createChatActionJournal({
|
|
348
|
+
baseDir = defaultBaseDir(),
|
|
349
|
+
now = () => new Date()
|
|
350
|
+
} = {}) {
|
|
351
|
+
const resolvedBaseDir = path.resolve(normalizeText(baseDir) || defaultBaseDir());
|
|
352
|
+
if (typeof now !== "function") {
|
|
353
|
+
throw journalError(
|
|
354
|
+
"CHAT_ACTION_JOURNAL_CLOCK_INVALID",
|
|
355
|
+
"Boss chat action journal now must be a function."
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function entryPath(input = {}) {
|
|
360
|
+
const identity = createChatActionIdentity(input);
|
|
361
|
+
return path.join(resolvedBaseDir, `${identity.actionKey}.json`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function read(input = {}) {
|
|
365
|
+
const identity = createChatActionIdentity(input);
|
|
366
|
+
const filePath = path.join(resolvedBaseDir, `${identity.actionKey}.json`);
|
|
367
|
+
const stored = readJsonRecord(filePath);
|
|
368
|
+
if (!stored) return null;
|
|
369
|
+
return cloneRecord(withRecordRevision(validateStoredRecord(stored, identity, filePath)));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function transition({
|
|
373
|
+
scope,
|
|
374
|
+
candidateId,
|
|
375
|
+
state,
|
|
376
|
+
runId = "",
|
|
377
|
+
greeting,
|
|
378
|
+
evidence = {},
|
|
379
|
+
recordIdempotent = false,
|
|
380
|
+
expectedUpdatedAt = null,
|
|
381
|
+
expectedRevision = null
|
|
382
|
+
} = {}) {
|
|
383
|
+
const identity = createChatActionIdentity({ scope, candidateId });
|
|
384
|
+
const nextState = requireState(state);
|
|
385
|
+
const filePath = path.join(resolvedBaseDir, `${identity.actionKey}.json`);
|
|
386
|
+
const lockPath = `${filePath}.lock`;
|
|
387
|
+
fs.mkdirSync(resolvedBaseDir, { recursive: true });
|
|
388
|
+
const lockHandle = acquireRecordLock(lockPath);
|
|
389
|
+
try {
|
|
390
|
+
const stored = readJsonRecord(filePath);
|
|
391
|
+
const existing = stored
|
|
392
|
+
? withRecordRevision(validateStoredRecord(stored, identity, filePath))
|
|
393
|
+
: null;
|
|
394
|
+
const observedRevision = recordRevision(existing);
|
|
395
|
+
if (expectedRevision != null) {
|
|
396
|
+
const normalizedExpectedRevision = Number(expectedRevision);
|
|
397
|
+
if (!Number.isSafeInteger(normalizedExpectedRevision) || normalizedExpectedRevision < 0) {
|
|
398
|
+
throw journalError(
|
|
399
|
+
"CHAT_ACTION_JOURNAL_REVISION_INVALID",
|
|
400
|
+
"Boss action journal expected revision must be a non-negative safe integer.",
|
|
401
|
+
{ expected_revision: expectedRevision }
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
if (observedRevision !== normalizedExpectedRevision) {
|
|
405
|
+
throw journalError(
|
|
406
|
+
"CHAT_ACTION_JOURNAL_CONCURRENT_UPDATE",
|
|
407
|
+
"Boss action journal changed after it was read; this operation does not own the outbound action.",
|
|
408
|
+
{
|
|
409
|
+
action_key: identity.actionKey,
|
|
410
|
+
expected_revision: normalizedExpectedRevision,
|
|
411
|
+
observed_revision: observedRevision,
|
|
412
|
+
expected_updated_at: normalizeText(expectedUpdatedAt) || null,
|
|
413
|
+
observed_updated_at: normalizeText(existing?.updated_at) || null
|
|
414
|
+
}
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (
|
|
419
|
+
expectedUpdatedAt != null
|
|
420
|
+
&& normalizeText(existing?.updated_at) !== normalizeText(expectedUpdatedAt)
|
|
421
|
+
) {
|
|
422
|
+
throw journalError(
|
|
423
|
+
"CHAT_ACTION_JOURNAL_CONCURRENT_UPDATE",
|
|
424
|
+
"Boss action journal changed after it was read; this operation does not own the outbound action.",
|
|
425
|
+
{
|
|
426
|
+
action_key: identity.actionKey,
|
|
427
|
+
expected_updated_at: normalizeText(expectedUpdatedAt) || null,
|
|
428
|
+
observed_updated_at: normalizeText(existing?.updated_at) || null
|
|
429
|
+
}
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
const suppliedGreeting = greeting == null ? "" : normalizeGreeting(greeting);
|
|
433
|
+
const suppliedGreetingSha256 = suppliedGreeting ? hashChatActionGreeting(suppliedGreeting) : "";
|
|
434
|
+
|
|
435
|
+
if (!existing && !suppliedGreetingSha256) {
|
|
436
|
+
throw journalError(
|
|
437
|
+
"CHAT_ACTION_GREETING_REQUIRED",
|
|
438
|
+
"The initial Boss chat action journal transition must include the greeting to hash."
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
if (
|
|
442
|
+
existing
|
|
443
|
+
&& suppliedGreetingSha256
|
|
444
|
+
&& suppliedGreetingSha256 !== existing.greeting_sha256
|
|
445
|
+
) {
|
|
446
|
+
throw journalError(
|
|
447
|
+
"CHAT_ACTION_GREETING_HASH_CONFLICT",
|
|
448
|
+
"The greeting does not match the greeting already journaled for this candidate.",
|
|
449
|
+
{ action_key: identity.actionKey }
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
if (!isAllowedTransition(existing, nextState)) {
|
|
453
|
+
throw journalError(
|
|
454
|
+
"CHAT_ACTION_TRANSITION_INVALID",
|
|
455
|
+
`Invalid Boss chat action journal transition: ${existing?.state || "<none>"} -> ${nextState}.`,
|
|
456
|
+
{
|
|
457
|
+
action_key: identity.actionKey,
|
|
458
|
+
current_state: existing?.state || null,
|
|
459
|
+
requested_state: nextState
|
|
460
|
+
}
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
if (existing?.state === nextState && recordIdempotent !== true) {
|
|
464
|
+
return {
|
|
465
|
+
changed: false,
|
|
466
|
+
idempotent: true,
|
|
467
|
+
file_path: filePath,
|
|
468
|
+
record: cloneRecord(existing)
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const at = nowIso(now);
|
|
473
|
+
const normalizedRunId = normalizeText(runId);
|
|
474
|
+
const safeEvidence = sanitizeEvidence(evidence);
|
|
475
|
+
const record = existing ? {
|
|
476
|
+
...existing,
|
|
477
|
+
state: nextState,
|
|
478
|
+
revision: observedRevision + 1,
|
|
479
|
+
updated_at: at,
|
|
480
|
+
evidence: {
|
|
481
|
+
...(existing.evidence && typeof existing.evidence === "object" ? existing.evidence : {}),
|
|
482
|
+
...safeEvidence
|
|
483
|
+
},
|
|
484
|
+
last_run_id: normalizedRunId || existing.last_run_id || null,
|
|
485
|
+
run_ids: appendRunId(existing.run_ids, normalizedRunId),
|
|
486
|
+
history: [
|
|
487
|
+
...existing.history,
|
|
488
|
+
{
|
|
489
|
+
from_state: existing.state,
|
|
490
|
+
state: nextState,
|
|
491
|
+
at,
|
|
492
|
+
run_id: normalizedRunId || null,
|
|
493
|
+
evidence: safeEvidence
|
|
494
|
+
}
|
|
495
|
+
]
|
|
496
|
+
} : {
|
|
497
|
+
schema_version: CHAT_ACTION_JOURNAL_SCHEMA_VERSION,
|
|
498
|
+
action_key: identity.actionKey,
|
|
499
|
+
scope_sha256: identity.scopeSha256,
|
|
500
|
+
candidate_id: identity.candidateId,
|
|
501
|
+
state: nextState,
|
|
502
|
+
revision: 1,
|
|
503
|
+
greeting_sha256: suppliedGreetingSha256,
|
|
504
|
+
evidence: safeEvidence,
|
|
505
|
+
created_at: at,
|
|
506
|
+
updated_at: at,
|
|
507
|
+
first_run_id: normalizedRunId || null,
|
|
508
|
+
last_run_id: normalizedRunId || null,
|
|
509
|
+
run_ids: appendRunId([], normalizedRunId),
|
|
510
|
+
history: [{
|
|
511
|
+
from_state: null,
|
|
512
|
+
state: nextState,
|
|
513
|
+
at,
|
|
514
|
+
run_id: normalizedRunId || null,
|
|
515
|
+
evidence: safeEvidence
|
|
516
|
+
}]
|
|
517
|
+
};
|
|
518
|
+
writeJsonAtomic(filePath, record);
|
|
519
|
+
return {
|
|
520
|
+
changed: true,
|
|
521
|
+
idempotent: false,
|
|
522
|
+
file_path: filePath,
|
|
523
|
+
record: cloneRecord(record)
|
|
524
|
+
};
|
|
525
|
+
} finally {
|
|
526
|
+
releaseRecordLock(lockPath, lockHandle);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return Object.freeze({
|
|
531
|
+
base_dir: resolvedBaseDir,
|
|
532
|
+
entryPath,
|
|
533
|
+
read,
|
|
534
|
+
transition
|
|
535
|
+
});
|
|
536
|
+
}
|