@dreamforgestudiollc/emet 0.0.0-stage → 0.2.4
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/.env.example +136 -0
- package/CHANGELOG.md +296 -0
- package/CHARTER.md +125 -0
- package/LICENSE +22 -0
- package/README.md +783 -2
- package/docs/AUTHORSHIP.md +26 -0
- package/docs/BENCHMARKS.md +261 -0
- package/docs/BOOTSTRAP.md +127 -0
- package/docs/BOTS.md +79 -0
- package/docs/COVERAGE.md +64 -0
- package/docs/DEPLOY.md +246 -0
- package/docs/DISCIPLINES.md +29 -0
- package/docs/GUARDRAILS-AND-MONORAILS.md +107 -0
- package/docs/GUIDED-SETUP.md +296 -0
- package/docs/PERMISSIONS.md +118 -0
- package/docs/ROADMAP.md +59 -0
- package/docs/SETUP.md +259 -0
- package/guides/NEURODIVERGENT_SUPPORT.md +278 -0
- package/package.json +70 -4
- package/server.json +39 -0
- package/src/aleph.js +395 -0
- package/src/annotations.js +135 -0
- package/src/approve-throttle.js +54 -0
- package/src/atlas-role.js +58 -0
- package/src/conformance.js +688 -0
- package/src/content_analyzer.js +450 -0
- package/src/database.js +811 -0
- package/src/disciplines.js +26 -0
- package/src/dispatch.js +279 -0
- package/src/env.js +52 -0
- package/src/floor.js +187 -0
- package/src/gaps.js +476 -0
- package/src/guides.js +48 -0
- package/src/http-remote.js +269 -0
- package/src/index.js +390 -0
- package/src/invites.js +167 -0
- package/src/layers.js +449 -0
- package/src/lessons-to-checks.js +366 -0
- package/src/logger.js +299 -0
- package/src/metadata.js +61 -0
- package/src/module-path.js +32 -0
- package/src/mongo-connect.js +48 -0
- package/src/named-items.js +208 -0
- package/src/oauth-provider.js +838 -0
- package/src/persons.js +116 -0
- package/src/provenance.js +257 -0
- package/src/redact.js +231 -0
- package/src/request-log.js +124 -0
- package/src/scopes.js +97 -0
- package/src/security-headers.js +26 -0
- package/src/session-graph.js +945 -0
- package/src/session.js +1097 -0
- package/src/setup.js +1313 -0
- package/src/startup-pages.js +354 -0
- package/src/startup-payload.js +126 -0
- package/src/tools.js +4271 -0
- package/src/transcript-session.js +106 -0
- package/src/validation.js +1163 -0
- package/src/version.js +2 -0
- package/templates/BOARD.md +72 -0
- package/templates/BOTHANDOFF.md +57 -0
- package/templates/CHARACTER.md +30 -0
- package/templates/HANDOFF.md +63 -0
- package/templates/IDENTITY.md +29 -0
- package/templates/INDEX.md +82 -0
- package/templates/INSTALL.md +29 -0
- package/templates/PROCEDURE.md +39 -0
- package/templates/ROSTER.md +43 -0
- package/templates/SEED.md +65 -0
- package/templates/STAGING.md +37 -0
- package/templates/STANDARD.md +99 -0
- package/templates/THREAD.md +43 -0
- package/templates/USER.md +29 -0
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMET - the session graph: the monorail under the guardrails (2026-09-29;
|
|
3
|
+
* the practice is docs/GUARDRAILS-AND-MONORAILS.md).
|
|
4
|
+
*
|
|
5
|
+
* The owner: "guardrails give a field of movement and execution to allow an AI to do
|
|
6
|
+
* processes where rails guide the AI through a process flow without it
|
|
7
|
+
* processing" (a recorded lesson). The floor, the character and the served
|
|
8
|
+
* instructions are the guardrails. This file is the monorail - the car cannot
|
|
9
|
+
* leave the beam; where a flow forks, a small switch owned by the track (never
|
|
10
|
+
* the model) picks the branch (designed 2026-09-29, not yet built): the next step of a
|
|
11
|
+
* session is decided by the session's STATE, read before every call, not by
|
|
12
|
+
* the model remembering it.
|
|
13
|
+
*
|
|
14
|
+
* The graph, for one session:
|
|
15
|
+
* (not open) --session_open / first append--> open
|
|
16
|
+
* open: turn k = any reads, writes that may declare `exchange: k`, then append k
|
|
17
|
+
* close chain, served order: board, episodic record, transcript, HANDOFF last
|
|
18
|
+
* emet_session_close returns complete --> closed (stop condition)
|
|
19
|
+
*
|
|
20
|
+
* Gates (each names the fix; nothing is written when one refuses):
|
|
21
|
+
* not_open a write names a session that was never opened
|
|
22
|
+
* turn_skipped a write or append declares exchange n while n-1 is not appended
|
|
23
|
+
* handoff_early HANDOFF.md written before the board, the episodic record
|
|
24
|
+
* and the transcript exist for this session; for a bot tag's
|
|
25
|
+
* own bots/<tag>/HANDOFF.md, before the episodic record and
|
|
26
|
+
* the transcript (no board; C1, 2026-10-01)
|
|
27
|
+
* no_session a write names no session at all (2026-10-03): open
|
|
28
|
+
* one with emet_session_open, or pass the session_id
|
|
29
|
+
* already opened, and resubmit the same write
|
|
30
|
+
* startup_pages emet_session_open with no load_id, or with pages of
|
|
31
|
+
* that load never fetched (2026-10-03)
|
|
32
|
+
* closed any write after a complete close, except the one
|
|
33
|
+
* closing exchange inside the grace window and a
|
|
34
|
+
* transcript correction (2026-10-01; the transcript doors
|
|
35
|
+
* enforce the same rule outright, below)
|
|
36
|
+
*
|
|
37
|
+
* The honest limit: the server sees tool calls, never the reply text. A host
|
|
38
|
+
* that answers without calling any tool is invisible until its next call; turn
|
|
39
|
+
* coverage is only as strong as the exchange numbers hosts report. Hosts we run
|
|
40
|
+
* ourselves (a Claude Code hook, an owned runner) can be railed completely.
|
|
41
|
+
*
|
|
42
|
+
* MODE: enforce only (2026-10-03). Every skipped step is refused before
|
|
43
|
+
* anything is written, as a ValidationError whose text says how to pass, and
|
|
44
|
+
* the refusal is recorded in the lab. There is no report mode and no off
|
|
45
|
+
* switch; EMET_SESSION_GRAPH is no longer read. A governed write that names a
|
|
46
|
+
* session fails closed when the graph state cannot be read (dispatch.js).
|
|
47
|
+
* A write that names no session still records verdict `unknown` and continues,
|
|
48
|
+
* because there is no session to protect.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { setting } from './env.js';
|
|
52
|
+
|
|
53
|
+
export const GRAPH_MODES = Object.freeze(['enforce']);
|
|
54
|
+
|
|
55
|
+
/** Always 'enforce'. Kept as a function so every block and lab row names its mode. */
|
|
56
|
+
export function graphMode() {
|
|
57
|
+
return 'enforce';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Tools the graph governs: every tool that writes. Reads are never gated. */
|
|
61
|
+
export const GRAPH_WRITE_TOOLS = Object.freeze(new Set([
|
|
62
|
+
'save_to_layer', 'revise_memory', 'write_doc', 'patch_doc', 'retire_doc', 'rename_doc', 'restore_doc',
|
|
63
|
+
'accept_nonconformance', 'emet_transcript_append', 'save_transcript', 'revise_transcript',
|
|
64
|
+
'emet_session_close'
|
|
65
|
+
]));
|
|
66
|
+
|
|
67
|
+
export const HANDOFF_DOC = 'HANDOFF.md';
|
|
68
|
+
export const BOARD_DOC = 'STATE.md';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Per-bot close records (C1, 2026-10-01; the owner's decisions 1-7). The rule, stated once: a session written
|
|
72
|
+
* under a bot's source tag closes against that bot's own records
|
|
73
|
+
* (bots/<tag>/HANDOFF.md); only that tag's own revisions in the session window
|
|
74
|
+
* count; a bot tag never writes or cites the main HANDOFF.md or STATE.md.
|
|
75
|
+
*
|
|
76
|
+
* A tag T is a BOT TAG when bots/T/HANDOFF.md is listed in EMET_RECORD_DOCS,
|
|
77
|
+
* the list that already marks ids as supersede-only records (tools.js
|
|
78
|
+
* recordDocIds). No new setting: the owner adds one id per bot, and removing it
|
|
79
|
+
* puts that tag back on the host rules. Bot records are named by the source
|
|
80
|
+
* tag itself (decision 1), so no tag-to-name mapping exists. These are pure
|
|
81
|
+
* reads of settings - no database - so the graph can key its bot-handoff gate
|
|
82
|
+
* on a write's `source` without an extra read.
|
|
83
|
+
*/
|
|
84
|
+
export function botRecordDir(tag) { return `bots/${tag}/`; }
|
|
85
|
+
export function botHandoffDoc(tag) { return `bots/${tag}/HANDOFF.md`; }
|
|
86
|
+
|
|
87
|
+
/** Whether `tag` is a bot tag: bots/<tag>/HANDOFF.md is in EMET_RECORD_DOCS. */
|
|
88
|
+
export function isBotTag(tag) {
|
|
89
|
+
if (typeof tag !== 'string' || !tag.trim() || tag.includes('/')) return false;
|
|
90
|
+
// The same comma list tools.js recordDocIds() reads.
|
|
91
|
+
const listed = String(setting('RECORD_DOCS', '') || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
92
|
+
return listed.includes(botHandoffDoc(tag.trim()));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The hub's main records: the handoff and board ids session.js uses (EMET_HANDOFF_DOC / EMET_STATE_DOC). */
|
|
96
|
+
export function mainRecordIds() {
|
|
97
|
+
return [setting('HANDOFF_DOC', 'HANDOFF.md'), setting('STATE_DOC', 'STATE.md')];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The source tag a write names, read as dispatch.js sourceOf reads it
|
|
102
|
+
* (`source`, then the older aliases). Repeated here because dispatch imports
|
|
103
|
+
* this module. No default: a write with no source takes the host path in the
|
|
104
|
+
* graph.
|
|
105
|
+
*/
|
|
106
|
+
export function writeSourceOf(args = {}) {
|
|
107
|
+
const v = args.source ?? args.updated_by ?? args.retired_by ?? args.accepted_by ?? args.channel ?? null;
|
|
108
|
+
return typeof v === 'string' && v.trim() ? v.trim() : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether this call writes a bot's own handoff under that bot's tag: write_doc of bots/<source>/HANDOFF.md by a bot tag. */
|
|
112
|
+
export function isBotHandoffWrite(tool, args = {}) {
|
|
113
|
+
if (tool !== 'write_doc') return false;
|
|
114
|
+
const tag = writeSourceOf(args);
|
|
115
|
+
return !!tag && isBotTag(tag) && args.doc_id === botHandoffDoc(tag);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The base session id of a part id (2026-09-30_x_verbatim_part2 -> 2026-09-30_x). */
|
|
119
|
+
export function baseOf(sessionId) {
|
|
120
|
+
return String(sessionId || '').replace(/_verbatim_part\d+$/, '') || null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A caller's id as a refusal echoes it: capped at 64 characters, so a long id
|
|
125
|
+
* cannot push the pass instruction past the 500-character error cap
|
|
126
|
+
* (database.js sanitizeErrorMessage).
|
|
127
|
+
*/
|
|
128
|
+
export const ID_ECHO_MAX = 64;
|
|
129
|
+
export function echoId(id) {
|
|
130
|
+
const s = String(id ?? '');
|
|
131
|
+
const cps = Array.from(s); // whole characters: never a lone surrogate in the echo
|
|
132
|
+
return cps.length > ID_ECHO_MAX ? `${cps.slice(0, ID_ECHO_MAX).join('')}…` : s;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Every session base an id could belong to, the way the close check groups a
|
|
137
|
+
* session's documents (session.js inspectSession: ^base(_.*)?$). An id like
|
|
138
|
+
* `<base>_extra` is counted by the close as part of `<base>`, so the closed-
|
|
139
|
+
* session guard must treat it as `<base>` too (2026-10-01, the reviewer's gate
|
|
140
|
+
* (a)). Longest first: the id itself, then each prefix ending before a `_`.
|
|
141
|
+
*/
|
|
142
|
+
export function candidateBases(sessionId) {
|
|
143
|
+
const sid = String(sessionId || '').trim();
|
|
144
|
+
if (!sid) return [];
|
|
145
|
+
const out = [sid];
|
|
146
|
+
for (let i = sid.length - 1; i > 0; i--) if (sid[i] === '_') out.push(sid.slice(0, i));
|
|
147
|
+
return [...new Set(out)];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Whether `id` is `base` itself or a part id the server mints for it (<base>_verbatim_partN). */
|
|
151
|
+
export function isMintedPartOf(id, base) {
|
|
152
|
+
const sid = String(id || '');
|
|
153
|
+
const prefix = `${base}_verbatim_part`;
|
|
154
|
+
return !!base && (sid === base || (sid.startsWith(prefix) && /^\d+$/.test(sid.slice(prefix.length))));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Whether `sessionId` is grouped under `base` by the close check (^base(_.*)?$). */
|
|
158
|
+
export function groupedUnder(sessionId, base) {
|
|
159
|
+
const sid = String(sessionId || '');
|
|
160
|
+
return !!base && (sid === base || sid.startsWith(`${base}_`));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The session a call names: `session_id`, else the transcript span in
|
|
165
|
+
* `metadata.derived_from` (revise_memory refuses metadata.session_id and names
|
|
166
|
+
* its session there - live-caught 2026-09-29, a recorded lesson), else - for
|
|
167
|
+
* emet_session_close, the only tool that takes `receipts` -
|
|
168
|
+
* `receipts.transcript_session_id`. The close tool's description documents
|
|
169
|
+
* that shape ("receipts.transcript_session_id is used when this is omitted")
|
|
170
|
+
* and sessionClose checks that session; until 2026-10-01 the graph read only
|
|
171
|
+
* `session_id`, so a close naming its session only in the receipts was never
|
|
172
|
+
* recorded and the session stayed open (the second reader's nit 3 on an earlier draft, the reviewer's
|
|
173
|
+
* condition 1). Receipts sent as JSON text are read as sessionClose reads
|
|
174
|
+
* them (session.js parseReceipts).
|
|
175
|
+
*/
|
|
176
|
+
export function sessionOf(args = {}) {
|
|
177
|
+
const pick = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
178
|
+
return pick(args.session_id) ||
|
|
179
|
+
pick(args.metadata && args.metadata.derived_from && args.metadata.derived_from.session_id) ||
|
|
180
|
+
pick(receiptsOf(args.receipts) && receiptsOf(args.receipts).transcript_session_id);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The close's receipts as an object (an object, or JSON text of one), else null. Mirrors session.js parseReceipts. */
|
|
184
|
+
function receiptsOf(value) {
|
|
185
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
|
186
|
+
if (typeof value === 'string' && value.trim().startsWith('{')) {
|
|
187
|
+
try {
|
|
188
|
+
const parsed = JSON.parse(value);
|
|
189
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
|
190
|
+
} catch { /* not JSON - treated as not supplied, as sessionClose does */ }
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* An exchange number as sent, normalized ONCE (2026-10-01, the second reader's re-check of
|
|
197
|
+
* an earlier draft). Hosts send numbers as numbers or as text ("4"). The guard read "4"
|
|
198
|
+
* as 4 while emet_transcript_append only took real numbers, so a text "4" was a
|
|
199
|
+
* "retry" to the guard and "the next exchange" to the tool. dispatch now runs
|
|
200
|
+
* every exchange_index through this before the graph, the guard and the tool,
|
|
201
|
+
* so all three see the same number.
|
|
202
|
+
* absent (undefined, null, '') -> null (the tool numbers it next)
|
|
203
|
+
* a whole number >= 1, or its text -> that number
|
|
204
|
+
* anything else -> {error}
|
|
205
|
+
*/
|
|
206
|
+
export function normalizeExchangeNumber(v) {
|
|
207
|
+
if (v === undefined || v === null || (typeof v === 'string' && v.trim() === '')) return { value: null };
|
|
208
|
+
let n = v;
|
|
209
|
+
if (typeof n === 'string' && /^\s*\d+\s*$/.test(n)) n = Number(n);
|
|
210
|
+
if (typeof n === 'number' && Number.isInteger(n) && n >= 1) return { value: n };
|
|
211
|
+
return { error: `exchange_index must be a whole number, 1 or more (got ${JSON.stringify(v)})` };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The exchange a call declares, if any: `exchange` on writes, `exchange_index` on an append. */
|
|
215
|
+
export function declaredExchange(tool, args) {
|
|
216
|
+
let v = tool === 'emet_transcript_append' ? args.exchange_index : args.exchange;
|
|
217
|
+
// Hosts send numbers as numbers or as text ("18") depending on how they read
|
|
218
|
+
// the schema - found on the first live call, 2026-09-29. Both are the same claim.
|
|
219
|
+
if (typeof v === 'string' && /^\s*\d+\s*$/.test(v)) v = Number(v);
|
|
220
|
+
return Number.isInteger(v) && v > 0 ? v : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Worded like the transcript doors' refusal (2026-10-01): "open a new session"
|
|
224
|
+
// pushed hosts into stray -N sessions opened just for one late write.
|
|
225
|
+
export const CLOSED_NEXT = 'none in this session: it is closed and locked, and nothing more is written to it. ' +
|
|
226
|
+
'Further work belongs in the next session, opened the usual way at the next session start; do not open a session just for it';
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* What the state says the next step is. `state` is the stored record
|
|
230
|
+
* ({opened, board, episodic, handoff, closed}); `highest` is the transcript's
|
|
231
|
+
* highest appended exchange (0 when none).
|
|
232
|
+
*/
|
|
233
|
+
export function nextStep(state = {}, highest = 0) {
|
|
234
|
+
const opened = !!state.opened || highest > 0;
|
|
235
|
+
if (!opened) return 'emet_session_open, then emet_transcript_append exchange 1 after your first reply';
|
|
236
|
+
if (state.closed) return CLOSED_NEXT;
|
|
237
|
+
if (state.handoff) return 'emet_session_close with receipts; the handoff is written';
|
|
238
|
+
return `emet_transcript_append exchange ${highest + 1} after this reply`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The decision, pure. Returns null for a tool the graph does not govern, else
|
|
243
|
+
* {verdict: 'ok'|'refuse', gate, reason, next}.
|
|
244
|
+
*/
|
|
245
|
+
export function evaluate(tool, args = {}, state = {}, highest = 0, now = new Date()) {
|
|
246
|
+
if (!GRAPH_WRITE_TOOLS.has(tool)) return null;
|
|
247
|
+
const next = nextStep(state, highest);
|
|
248
|
+
const refuse = (gate, reason, owed = next) => ({ verdict: 'refuse', gate, reason, next: owed });
|
|
249
|
+
const opened = !!state.opened || highest > 0;
|
|
250
|
+
const n = declaredExchange(tool, args);
|
|
251
|
+
|
|
252
|
+
if (!opened && !(tool === 'emet_transcript_append' && (n === null || n === 1))) {
|
|
253
|
+
if (tool === 'write_doc' && typeof args.doc_id === 'string' && args.doc_id.startsWith('drops/')) {
|
|
254
|
+
return { verdict: 'ok', gate: 'drop_only', reason: null, next: 'open a session to write anything other than a new drop' };
|
|
255
|
+
}
|
|
256
|
+
return refuse('not_open', `session ${echoId(baseOf(sessionOf(args)))} was never opened. Open it with emet_session_open ` +
|
|
257
|
+
'(with the load_id from your startup pages), then resubmit this same write with the session_id it returns. Until then this session may read, and may write a new drop only.');
|
|
258
|
+
}
|
|
259
|
+
const lateOk = tool === 'revise_transcript' ||
|
|
260
|
+
(tool === 'emet_transcript_append' && ['grace', 'retry'].includes(graceDecision(state, n, highest, now)));
|
|
261
|
+
if (state.closed && !lateOk) {
|
|
262
|
+
return refuse('closed', `session ${echoId(baseOf(sessionOf(args)))} is closed and locked, so nothing was written to it. ` +
|
|
263
|
+
'This belongs in the next session you open the usual way, at the next session start; do not open a session just for it.',
|
|
264
|
+
'the same write in the next session, at the next session start');
|
|
265
|
+
}
|
|
266
|
+
if (n !== null && n > highest + 1) {
|
|
267
|
+
return refuse('turn_skipped', `exchange ${highest + 1} of session ${echoId(baseOf(sessionOf(args)))} was never appended. Append it with ` +
|
|
268
|
+
`emet_transcript_append (this session_id, exchange_index ${highest + 1}, that turn's user message and reply)` +
|
|
269
|
+
`${highest + 2 < n ? `, and each exchange after it up to ${n - 1}` : ''}, then resubmit this same call.`);
|
|
270
|
+
}
|
|
271
|
+
// A bot's own handoff takes the same gate, without the board: a bot session
|
|
272
|
+
// has no board unless it keeps one as a working document (C1, decision 5).
|
|
273
|
+
const botHandoff = isBotHandoffWrite(tool, args);
|
|
274
|
+
if (tool === 'write_doc' && (args.doc_id === HANDOFF_DOC || botHandoff)) {
|
|
275
|
+
const missing = [];
|
|
276
|
+
if (!botHandoff && !state.board) missing.push(`the board (${BOARD_DOC}) with patch_doc`);
|
|
277
|
+
if (!state.episodic) missing.push('the episodic session record with save_to_layer (layer "episodic")');
|
|
278
|
+
if (!(highest > 0)) missing.push('the transcript with emet_transcript_append');
|
|
279
|
+
if (missing.length) {
|
|
280
|
+
return refuse('handoff_early', `the handoff is written last. First write ${missing.join(', ')}, each with this ` +
|
|
281
|
+
'session_id, then resubmit this same handoff write.');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// An allowed call reports the step AFTER itself: an append of exchange n
|
|
285
|
+
// points to n+1, a handoff points to close. (Live-caught 2026-09-29: the
|
|
286
|
+
// pre-call value named the exchange just appended.) A refusal keeps the
|
|
287
|
+
// pre-call step, because that step is still the one owed.
|
|
288
|
+
let after = { state, highest };
|
|
289
|
+
if (tool === 'emet_transcript_append' && n !== null && n === highest + 1) after = { state: { ...state, opened: true }, highest: n };
|
|
290
|
+
if (tool === 'write_doc' && (args.doc_id === HANDOFF_DOC || botHandoff)) after = { state: { ...state, handoff: true }, highest };
|
|
291
|
+
return { verdict: 'ok', gate: null, reason: null, next: nextStep(after.state, after.highest) };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* What a SUCCESSFUL call adds to the session's state. Returns the fields to
|
|
296
|
+
* set, or null. `data` is the tool's result payload.
|
|
297
|
+
*/
|
|
298
|
+
export function recordStep(tool, args = {}, data = {}, now = new Date()) {
|
|
299
|
+
if (tool === 'emet_session_open') {
|
|
300
|
+
// A suffixed id records what it is the suffix of, so a real slug ending in
|
|
301
|
+
// -N is never handed the same id (and vice versa).
|
|
302
|
+
const of = data && data.suffix_applied && data.suffix_applied.requested;
|
|
303
|
+
return { opened: true, opened_at: now, ...(of ? { suffix_of: of } : {}) };
|
|
304
|
+
}
|
|
305
|
+
// An append never opens a board-editing session. The first append is still
|
|
306
|
+
// allowed by evaluate(); the board path requires a verified drops_seen array
|
|
307
|
+
// stamped only by emet_session_open.
|
|
308
|
+
if (tool === 'emet_transcript_append' || tool === 'save_transcript') return null;
|
|
309
|
+
if (tool === 'save_to_layer' && args.layer === 'episodic') return { episodic: true, episodic_id: data && data.id };
|
|
310
|
+
if ((tool === 'write_doc' || tool === 'patch_doc' || tool === 'restore_doc') && args.doc_id === BOARD_DOC) return { board: true, board_version: data && data.version };
|
|
311
|
+
if (tool === 'write_doc' && (args.doc_id === HANDOFF_DOC || isBotHandoffWrite(tool, args))) return { handoff: true, handoff_version: data && data.version };
|
|
312
|
+
if (tool === 'emet_session_close' && data && data.state === 'complete') return { closed: true, closed_at: now };
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// The thin store layer. The state lives in `<prefix>sessions`, one document per
|
|
318
|
+
// base session id. This file owns that collection and writes nothing else.
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
async function sessionsCollection(dbManager) {
|
|
322
|
+
const { COLLECTION_PREFIX } = await import('./database.js');
|
|
323
|
+
const db = await dbManager.ensureClient();
|
|
324
|
+
return db.collection(`${COLLECTION_PREFIX}sessions`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A session's recorded state, for emet_session_open (2026-10-01): whether a
|
|
329
|
+
* row exists, whether it was closed complete, and - if the id was minted as a
|
|
330
|
+
* `-N` suffix - which id it is the suffix of. Throws when the store cannot be
|
|
331
|
+
* read; the caller must not treat "unknown" as "free".
|
|
332
|
+
*/
|
|
333
|
+
export async function sessionStatus(base, dbManager) {
|
|
334
|
+
const col = await sessionsCollection(dbManager);
|
|
335
|
+
const row = await col.findOne({ _id: base }, { projection: { closed: 1, suffix_of: 1 } });
|
|
336
|
+
return { exists: !!row, closed: !!(row && row.closed), suffix_of: (row && row.suffix_of) || null };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Whether a session's state says it was closed complete. Throws when the store cannot be read. */
|
|
340
|
+
export async function isSessionClosed(base, dbManager) {
|
|
341
|
+
return (await sessionStatus(base, dbManager)).closed;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The closing exchange (2026-10-01, the owner's decision). In the closing turn the
|
|
346
|
+
* assistant writes the handoff, gets `complete` from emet_session_close, and
|
|
347
|
+
* only then replies - so the append of that last exchange always arrives
|
|
348
|
+
* after the close. A closed session therefore takes exactly ONE more append:
|
|
349
|
+
* numbered highest+1, within EMET_CLOSE_GRACE_MINUTES (default 5; 0 turns the
|
|
350
|
+
* window off) of the RECORDED close time `closed_at`. Once it is saved, or
|
|
351
|
+
* once the window passes, the session is locked. A retry of that same
|
|
352
|
+
* exchange number is answered as already saved, never as a second write:
|
|
353
|
+
* the guard answers it itself and the tool is not called, at any time after
|
|
354
|
+
* the close, so a retry can write nothing anywhere (2026-10-01, the second reader's blocker
|
|
355
|
+
* and the reviewer's gate on an earlier draft). The closing exchange itself is written
|
|
356
|
+
* into the session's last live part, whatever part or base id the caller sent.
|
|
357
|
+
*
|
|
358
|
+
* graceDecision is pure: state is the session row, declared the exchange the
|
|
359
|
+
* append names (null when none), highest the transcript's highest exchange.
|
|
360
|
+
* grace the one closing exchange may be saved now
|
|
361
|
+
* retry the closing exchange was saved; this is the same number again
|
|
362
|
+
* used the closing exchange was already saved
|
|
363
|
+
* expired the window has passed
|
|
364
|
+
* not_next the append names a number other than highest+1
|
|
365
|
+
* none no window applies (not closed, closed_at not recorded, or off)
|
|
366
|
+
*/
|
|
367
|
+
export const DEFAULT_CLOSE_GRACE_MINUTES = 5;
|
|
368
|
+
export const MAX_CLOSE_GRACE_MINUTES = 60;
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The window setting, checked (2026-10-01, the second reader's nit 4). Unset or blank: the
|
|
372
|
+
* default, 5. A number from 0 to 60: that many minutes (0 turns the window
|
|
373
|
+
* off). Anything else is NOT silently accepted: a negative or non-numeric value
|
|
374
|
+
* falls back to the default 5, and a value over 60 is capped at 60; both come
|
|
375
|
+
* back with a `note` that the guard logs as a warning. Only decimal numbers
|
|
376
|
+
* count: "0x10" or "0b11" is non-numeric here. Returns {minutes, note}.
|
|
377
|
+
*
|
|
378
|
+
* The name is EMET_CLOSE_GRACE_MINUTES. It is read through env.js setting(),
|
|
379
|
+
* so the legacy CASCADE_CLOSE_GRACE_MINUTES is still read as a deprecated
|
|
380
|
+
* fallback; EMET_ wins when both are set. (Not every EMET_ setting does this:
|
|
381
|
+
* a few read process.env directly - see
|
|
382
|
+
* docs/DEPLOY.md.)
|
|
383
|
+
*/
|
|
384
|
+
function closeGraceName() {
|
|
385
|
+
return process.env.EMET_CLOSE_GRACE_MINUTES === undefined && process.env.CASCADE_CLOSE_GRACE_MINUTES !== undefined
|
|
386
|
+
? 'CASCADE_CLOSE_GRACE_MINUTES (deprecated; use EMET_CLOSE_GRACE_MINUTES)'
|
|
387
|
+
: 'EMET_CLOSE_GRACE_MINUTES';
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function closeGraceSetting(raw = setting('CLOSE_GRACE_MINUTES'), name = closeGraceName()) {
|
|
391
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return { minutes: DEFAULT_CLOSE_GRACE_MINUTES, note: null };
|
|
392
|
+
// Decimal only: Number() also reads hex, octal and binary ("0x10" is 16),
|
|
393
|
+
// which no one means for minutes (the second reader's nit 5 on an earlier draft).
|
|
394
|
+
const text = String(raw).trim();
|
|
395
|
+
const n = /^[+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?$/i.test(text) ? Number(text) : NaN;
|
|
396
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
397
|
+
return { minutes: DEFAULT_CLOSE_GRACE_MINUTES,
|
|
398
|
+
note: `${name}=${JSON.stringify(String(raw))} is not a number of minutes from 0 to ${MAX_CLOSE_GRACE_MINUTES}; the default ${DEFAULT_CLOSE_GRACE_MINUTES} is used.` };
|
|
399
|
+
}
|
|
400
|
+
if (n > MAX_CLOSE_GRACE_MINUTES) {
|
|
401
|
+
return { minutes: MAX_CLOSE_GRACE_MINUTES,
|
|
402
|
+
note: `${name}=${JSON.stringify(String(raw))} is over the ${MAX_CLOSE_GRACE_MINUTES}-minute cap; ${MAX_CLOSE_GRACE_MINUTES} is used.` };
|
|
403
|
+
}
|
|
404
|
+
return { minutes: n, note: null };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function closeGraceMinutes(raw = setting('CLOSE_GRACE_MINUTES')) {
|
|
408
|
+
return closeGraceSetting(raw).minutes;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const graceNotesLogged = new Set();
|
|
412
|
+
function logGraceNote(note, logger) {
|
|
413
|
+
if (!note || graceNotesLogged.has(note)) return;
|
|
414
|
+
graceNotesLogged.add(note);
|
|
415
|
+
if (logger && typeof logger.warn === 'function') logger.warn(`closing exchange: ${note}`);
|
|
416
|
+
else console.error(`[emet] closing exchange: ${note}`); // stderr: stdout is the stdio transport's
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function graceDecision(state = {}, declared = null, highest = 0, now = new Date(), minutes = closeGraceMinutes()) {
|
|
420
|
+
if (!state || !state.closed) return 'none';
|
|
421
|
+
if (state.grace_used) return declared !== null && declared === state.grace_index ? 'retry' : 'used';
|
|
422
|
+
const at = state.closed_at ? new Date(state.closed_at).getTime() : NaN;
|
|
423
|
+
if (!(minutes > 0) || !Number.isFinite(at)) return 'none';
|
|
424
|
+
const elapsed = now.getTime() - at;
|
|
425
|
+
if (elapsed < 0 || elapsed > minutes * 60000) return 'expired';
|
|
426
|
+
if (declared !== null && declared !== highest + 1) return 'not_next';
|
|
427
|
+
return 'grace';
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Claims (or, on `release`, gives back) the closing exchange, atomically: the
|
|
432
|
+
* claim matches only a closed row whose grace is unused, so two racing
|
|
433
|
+
* appends cannot both win. Released when the claimed append then fails or
|
|
434
|
+
* wrote nothing, so a failed write does not spend the window. Returns true
|
|
435
|
+
* when the claim landed. Its own collection, `<prefix>sessions` - never a
|
|
436
|
+
* layer, document or transcript.
|
|
437
|
+
*/
|
|
438
|
+
export async function markCloseGrace(base, dbManager, { index = null, now = new Date(), release = false } = {}) {
|
|
439
|
+
const col = await sessionsCollection(dbManager);
|
|
440
|
+
if (release) {
|
|
441
|
+
await col.updateOne({ _id: base }, { $unset: { grace_used: '', grace_index: '', grace_used_at: '' } });
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
const res = await col.updateOne(
|
|
445
|
+
{ _id: base, closed: true, grace_used: { $ne: true } },
|
|
446
|
+
{ $set: { grace_used: true, grace_index: index, grace_used_at: now } }
|
|
447
|
+
);
|
|
448
|
+
return !!(res && res.modifiedCount === 1);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The transcript doors and a closed session (2026-10-01). Runs before the tool,
|
|
453
|
+
* so a refusal writes nothing. A closed session takes no new exchanges,
|
|
454
|
+
* except its one closing exchange (above).
|
|
455
|
+
*
|
|
456
|
+
* emet_transcript_append refused, except the closing exchange
|
|
457
|
+
* save_transcript refused (a whole-session save adds exchanges)
|
|
458
|
+
* revise_transcript ALLOWED - corrections supersede and delete
|
|
459
|
+
* nothing - but capped: a revision of a closed
|
|
460
|
+
* session's part may correct the exchanges that part
|
|
461
|
+
* holds, never add new ones, so the session's
|
|
462
|
+
* exchange count cannot grow.
|
|
463
|
+
*
|
|
464
|
+
* Fail-safe: if the session's state, its transcript
|
|
465
|
+
* position or the part to be revised cannot be read, nothing is written.
|
|
466
|
+
* Returns null (go ahead), or:
|
|
467
|
+
* {grace, base, index, part, notice} the closing exchange: dispatch writes it
|
|
468
|
+
* as exchange `index` into `part`, never
|
|
469
|
+
* where the caller pointed
|
|
470
|
+
* {retry, base, index, notice} a retry of the saved closing exchange:
|
|
471
|
+
* dispatch answers it and the tool is
|
|
472
|
+
* NOT called, so nothing can be written
|
|
473
|
+
* {revise, base, exchanges} a correction of a closed session:
|
|
474
|
+
* the exchanges, numbers normalized
|
|
475
|
+
*/
|
|
476
|
+
export const TRANSCRIPT_WRITE_TOOLS = Object.freeze(new Set(['emet_transcript_append', 'save_transcript', 'revise_transcript']));
|
|
477
|
+
|
|
478
|
+
async function refusal(field, message) {
|
|
479
|
+
const { ValidationError } = await import('./validation.js');
|
|
480
|
+
return new ValidationError(field, message);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function unreadable(tool, base, what, e) {
|
|
484
|
+
const { DatabaseError } = await import('./database.js');
|
|
485
|
+
return new DatabaseError(`${tool} could not read ${what} for session ${echoId(base)}, so nothing was written: ${e && e.message}. ` +
|
|
486
|
+
'Nothing was lost; send the same call again.', tool);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function closedSessionGuard(tool, args = {}, dbManager, now = new Date(), logger = null) {
|
|
490
|
+
if (!TRANSCRIPT_WRITE_TOOLS.has(tool)) return null;
|
|
491
|
+
const sid = typeof args.session_id === 'string' ? args.session_id.trim() : '';
|
|
492
|
+
if (!sid) return null;
|
|
493
|
+
|
|
494
|
+
// The closed session this id belongs to, grouped as the close check groups
|
|
495
|
+
// it: <base>, <base>_verbatim_partN and any other <base>_... id alike.
|
|
496
|
+
let row = null;
|
|
497
|
+
let base = baseOf(sid);
|
|
498
|
+
try {
|
|
499
|
+
const col = await sessionsCollection(dbManager);
|
|
500
|
+
for (const b of candidateBases(sid)) {
|
|
501
|
+
const r = await col.findOne({ _id: b }, { projection: { closed: 1, closed_at: 1, grace_used: 1, grace_index: 1 } });
|
|
502
|
+
if (r && r.closed) { row = r; base = b; break; }
|
|
503
|
+
}
|
|
504
|
+
} catch (e) { throw await unreadable(tool, base, 'whether the session is closed', e); }
|
|
505
|
+
if (!row) return null;
|
|
506
|
+
|
|
507
|
+
const locked = (why) => refusal('session_id',
|
|
508
|
+
`session ${echoId(base)} is closed and locked${why}, so nothing was written to it. Nothing in it was lost or changed. ` +
|
|
509
|
+
'This exchange belongs in the next session you open the usual way, at the next session start; do not open a session just for it.');
|
|
510
|
+
|
|
511
|
+
if (tool === 'revise_transcript') {
|
|
512
|
+
let part;
|
|
513
|
+
try {
|
|
514
|
+
const db = await dbManager.ensureClient();
|
|
515
|
+
const t = db.collection('transcripts');
|
|
516
|
+
part = await t.findOne({ session_id: sid, superseded_by: { $in: [null, undefined] } }) || await t.findOne({ session_id: sid });
|
|
517
|
+
} catch (e) { throw await unreadable(tool, base, 'the transcript part to be revised', e); }
|
|
518
|
+
if (!part) return null; // the tool itself refuses a revision of a part that does not exist
|
|
519
|
+
// A correction of a closed session keeps EXACTLY the exchange numbers the
|
|
520
|
+
// part holds (2026-10-01, the second reader's nit 2): none added, none dropped, none
|
|
521
|
+
// duplicated. Numbers sent as text are stored as numbers; the corrected
|
|
522
|
+
// part is stored in exchange order.
|
|
523
|
+
const held = [...new Set((part.exchanges || []).map((e) => Number(e && e.exchange_index)))].sort((a, b) => a - b);
|
|
524
|
+
const heldText = held.join(', ') || 'none';
|
|
525
|
+
const revised = Array.isArray(args.exchanges) ? args.exchanges : [];
|
|
526
|
+
const fail = async (what) => refusal('exchanges',
|
|
527
|
+
`session ${echoId(base)} is closed: a revision may correct the exchanges ${echoId(sid)} already holds (${held.length}: ${heldText}) ` +
|
|
528
|
+
`and must carry exactly those numbers: never add new ones, never drop or repeat one - ${what}. Nothing was written.`);
|
|
529
|
+
const numbered = [];
|
|
530
|
+
for (let i = 0; i < revised.length; i++) {
|
|
531
|
+
const ex = revised[i];
|
|
532
|
+
const raw = ex && ex.exchange_index !== undefined ? ex.exchange_index : i; // the tool's own default
|
|
533
|
+
const { value, error } = normalizeExchangeNumber(raw);
|
|
534
|
+
if (error || value === null) throw await fail(`exchange ${i + 1} of the revision has no valid exchange_index (${JSON.stringify(raw)})`);
|
|
535
|
+
numbered.push({ ...ex, exchange_index: value });
|
|
536
|
+
}
|
|
537
|
+
const nums = numbered.map((e) => e.exchange_index);
|
|
538
|
+
const heldSet = new Set(held);
|
|
539
|
+
const added = [...new Set(nums.filter((n) => !heldSet.has(n)))];
|
|
540
|
+
if (added.length) {
|
|
541
|
+
throw await fail(`exchange ${added.join(', ')} is not in that part` + (revised.length > held.length ? `, and the revision has ${revised.length}` : ''));
|
|
542
|
+
}
|
|
543
|
+
const repeated = [...new Set(nums.filter((n, i) => nums.indexOf(n) !== i))];
|
|
544
|
+
if (repeated.length) throw await fail(`exchange ${repeated.join(', ')} appears more than once`);
|
|
545
|
+
const dropped = held.filter((n) => !nums.includes(n));
|
|
546
|
+
if (dropped.length) throw await fail(`exchange ${dropped.join(', ')} is missing from the revision`);
|
|
547
|
+
return { revise: true, base, exchanges: numbered.sort((a, b) => a.exchange_index - b.exchange_index) };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (tool === 'save_transcript') throw await locked(' (a whole-transcript save adds exchanges, which a closed session does not take)');
|
|
551
|
+
|
|
552
|
+
// emet_transcript_append. dispatch has already normalized exchange_index.
|
|
553
|
+
let pos;
|
|
554
|
+
try {
|
|
555
|
+
pos = typeof dbManager.transcriptPosition === 'function' ? await dbManager.transcriptPosition(base) : null;
|
|
556
|
+
if (!pos || typeof pos.highest !== 'number') throw new Error('the transcript position is not available');
|
|
557
|
+
} catch (e) { throw await unreadable(tool, base, 'the transcript position', e); }
|
|
558
|
+
const highest = pos.highest;
|
|
559
|
+
const declared = declaredExchange(tool, args);
|
|
560
|
+
const setting = closeGraceSetting();
|
|
561
|
+
logGraceNote(setting.note, logger);
|
|
562
|
+
const minutes = setting.minutes;
|
|
563
|
+
const d = graceDecision(row, declared, highest, now, minutes);
|
|
564
|
+
|
|
565
|
+
if (d === 'retry') {
|
|
566
|
+
// Answered HERE, and the tool is never called (2026-10-01, the second reader's blocker on
|
|
567
|
+
// an earlier draft): a retry handed to the tool with the caller's arguments could
|
|
568
|
+
// land in another part id, the base id, or - with the number sent as text -
|
|
569
|
+
// as a new exchange, while the reply said nothing was written.
|
|
570
|
+
return { retry: true, base, index: row.grace_index,
|
|
571
|
+
notice: `Exchange ${row.grace_index} is already saved as the closing exchange of session ${base}. Nothing new was written; the session is locked.` };
|
|
572
|
+
}
|
|
573
|
+
if (d === 'grace') {
|
|
574
|
+
const index = highest + 1;
|
|
575
|
+
// Pinned to the session's last live part (2026-10-01, the second reader's nit 3), never
|
|
576
|
+
// the part or base id the caller named. If that part is full, the append
|
|
577
|
+
// rolls to the next part number exactly as a live append does.
|
|
578
|
+
let part = typeof pos.last_part === 'string' && pos.last_part ? pos.last_part : null;
|
|
579
|
+
if (!part && highest === 0) part = `${base}_verbatim_part1`;
|
|
580
|
+
if (!part || !isMintedPartOf(part, base)) {
|
|
581
|
+
throw await unreadable(tool, base, 'the last part of the transcript', new Error('the last part is not available'));
|
|
582
|
+
}
|
|
583
|
+
let claimed;
|
|
584
|
+
try { claimed = await markCloseGrace(base, dbManager, { index, now }); }
|
|
585
|
+
catch (e) { throw await unreadable(tool, base, 'the closing-exchange record', e); }
|
|
586
|
+
if (claimed) {
|
|
587
|
+
return { grace: true, base, index, part,
|
|
588
|
+
notice: `Accepted as the closing exchange (exchange ${index}) of session ${base}, within ${minutes} minutes of its close. ` +
|
|
589
|
+
'The session is now locked: no further exchanges are accepted.' };
|
|
590
|
+
}
|
|
591
|
+
throw await locked(' and its one closing exchange has already been saved');
|
|
592
|
+
}
|
|
593
|
+
const why = {
|
|
594
|
+
used: ' and its one closing exchange has already been saved',
|
|
595
|
+
expired: ` and the ${minutes}-minute window for its closing exchange has passed`,
|
|
596
|
+
not_next: ` - only exchange ${highest + 1} could be saved as its closing exchange`,
|
|
597
|
+
none: ''
|
|
598
|
+
}[d] || '';
|
|
599
|
+
throw await locked(why);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* The no_session gate's words (2026-10-03). Nothing was written, so the words
|
|
604
|
+
* ask for the same write again, with a session.
|
|
605
|
+
*/
|
|
606
|
+
export const VISITOR_REASON =
|
|
607
|
+
'Nothing was written. A visitor may read and leave a drop (drops/<date>-<slug>.md). Full access needs initialize, every page, and a registered source.';
|
|
608
|
+
export const NO_SESSION_REASON =
|
|
609
|
+
'this write names no session. Open one with emet_session_open (with the load_id from your startup pages), or use the ' +
|
|
610
|
+
'session_id of the session you already opened, then resubmit this same write with that session_id. ' + VISITOR_REASON;
|
|
611
|
+
export const NO_SESSION_NEXT = 'emet_session_open with load_id, then resubmit this same write with session_id';
|
|
612
|
+
export function noSessionDecision() {
|
|
613
|
+
return { verdict: 'refuse', gate: 'no_session', reason: NO_SESSION_REASON, next: NO_SESSION_NEXT };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The startup_pages gate (2026-10-03). emet_session_open with pages of the
|
|
618
|
+
* paged startup never fetched is a skipped step and is refused before
|
|
619
|
+
* anything is written. An open must name its startup: with no load_id the
|
|
620
|
+
* pages cannot be checked, so the open is refused - never checked against
|
|
621
|
+
* another load. A named load the server has no record of (made up, expired,
|
|
622
|
+
* or from before a restart) cannot be checked either, so it is refused too,
|
|
623
|
+
* with the way back: a fresh emet_initialize. Returns null when every page of
|
|
624
|
+
* the named load was fetched.
|
|
625
|
+
*/
|
|
626
|
+
export const STARTUP_PAGES_TOOL = 'emet_session_open';
|
|
627
|
+
export const NO_LOAD_ID_REASON =
|
|
628
|
+
'emet_session_open needs load_id, so no session was opened and nothing was written. Call emet_session_open again with ' +
|
|
629
|
+
'"load_id" set to startup_page.load_id from your emet_initialize pages (every page carries it); if you have no startup ' +
|
|
630
|
+
'pages, call emet_initialize {} and fetch every page first. ' + VISITOR_REASON;
|
|
631
|
+
export function noLoadIdDecision() {
|
|
632
|
+
return { verdict: 'refuse', gate: 'startup_pages', reason: NO_LOAD_ID_REASON, next: 'emet_session_open with "load_id" from your startup pages' };
|
|
633
|
+
}
|
|
634
|
+
/** Page numbers as ranges, once: [2,3,4,7,9,10] -> "2-4, 7, 9-10". */
|
|
635
|
+
export function pageRanges(pages = []) {
|
|
636
|
+
const out = [];
|
|
637
|
+
for (let i = 0; i < pages.length; i++) {
|
|
638
|
+
let j = i;
|
|
639
|
+
while (j + 1 < pages.length && pages[j + 1] === pages[j] + 1) j++;
|
|
640
|
+
out.push(j > i ? `${pages[i]}-${pages[j]}` : `${pages[i]}`);
|
|
641
|
+
i = j;
|
|
642
|
+
}
|
|
643
|
+
return out.join(', ');
|
|
644
|
+
}
|
|
645
|
+
// The pass instruction comes first, and the missing pages are named once, as
|
|
646
|
+
// ranges, so the refusal fits the 500-character error-message cap
|
|
647
|
+
// (database.js sanitizeErrorMessage); pages too scattered to list in 60
|
|
648
|
+
// characters are asked for again from page 1. Tests pin 20 and 40 missing.
|
|
649
|
+
export const PAGE_LIST_MAX = 60;
|
|
650
|
+
export function startupPagesDecision(c) {
|
|
651
|
+
const one = c.missing.length === 1;
|
|
652
|
+
const first = `emet_initialize {"page": ${c.missing[0]}, "load_id": "${c.load_id}"}`;
|
|
653
|
+
const ranges = pageRanges(c.missing);
|
|
654
|
+
if (ranges.length > PAGE_LIST_MAX) {
|
|
655
|
+
// Scattered pages too many to list: fetch every page again (harmless).
|
|
656
|
+
return { verdict: 'refuse', gate: 'startup_pages',
|
|
657
|
+
reason: `Fetch every startup page of load ${c.load_id}, 1 to ${c.of}: emet_initialize {"page": 1, "load_id": "${c.load_id}"}, then the ` +
|
|
658
|
+
`same call for each page after it; then call emet_session_open again with "load_id": "${c.load_id}". ${c.missing.length} of its ` +
|
|
659
|
+
`${c.of} pages were never fetched, so no session was opened and nothing was written. If told the startup changed, call emet_initialize {} and read every page.`,
|
|
660
|
+
next: 'every page, then emet_session_open again' };
|
|
661
|
+
}
|
|
662
|
+
return { verdict: 'refuse', gate: 'startup_pages',
|
|
663
|
+
reason: `Fetch startup page${one ? '' : 's'} ${ranges} of ${c.of} (load ${c.load_id}): ${first}` +
|
|
664
|
+
`${one ? '' : ', then the same call for each page after it in that list'}; then call emet_session_open again with ` +
|
|
665
|
+
`"load_id": "${c.load_id}". ${one ? 'That page was' : 'Those pages were'} never fetched, so no session was opened and nothing was written. ` +
|
|
666
|
+
'If told the startup changed, call emet_initialize {} and read every page.',
|
|
667
|
+
next: 'the missing pages, then emet_session_open again' };
|
|
668
|
+
}
|
|
669
|
+
// A load_id with no record here (never served by this process, expired after
|
|
670
|
+
// 60 minutes without a page, or from before a restart) cannot be checked, so
|
|
671
|
+
// the open is refused: pages are never taken as checked when they were not.
|
|
672
|
+
export const UNKNOWN_LOAD_ID_REASON = (id) =>
|
|
673
|
+
'Call emet_initialize {} and fetch every page, then call emet_session_open again with the load_id it returns. ' +
|
|
674
|
+
`Startup load ${echoId(id)} is not on record on this server (it was not served here, it expired after 60 minutes ` +
|
|
675
|
+
'without a page, or the server restarted), so its pages cannot be checked: no session was opened and nothing was written.';
|
|
676
|
+
export function unknownLoadIdDecision(id) {
|
|
677
|
+
return { verdict: 'refuse', gate: 'startup_pages', reason: UNKNOWN_LOAD_ID_REASON(id), next: 'emet_initialize {}, every page, then emet_session_open with its load_id' };
|
|
678
|
+
}
|
|
679
|
+
// The fetched-page record the gate reads is held in THIS process only: pages
|
|
680
|
+
// served by another server instance are not on it, so enforce assumes EMET
|
|
681
|
+
// runs as one process. With no load_id the open is unchecked - never checked
|
|
682
|
+
// against the latest startup this server built, which may be another host's.
|
|
683
|
+
export async function startupPagesGate(args = {}, now = new Date()) {
|
|
684
|
+
const mode = graphMode();
|
|
685
|
+
const loadId = typeof args.load_id === 'string' ? args.load_id.trim() : '';
|
|
686
|
+
let base = null;
|
|
687
|
+
try {
|
|
688
|
+
const { sessionBaseId, sessionTimeZone } = await import('./transcript-session.js');
|
|
689
|
+
base = sessionBaseId(now, args.slug || 'session', sessionTimeZone().zone); // the id asked for; a closed one opens as -2
|
|
690
|
+
} catch { base = null; }
|
|
691
|
+
let decision; let startupPages;
|
|
692
|
+
if (!loadId) {
|
|
693
|
+
decision = noLoadIdDecision();
|
|
694
|
+
startupPages = { checked: false, load_id: null, reason: 'no load_id' };
|
|
695
|
+
} else {
|
|
696
|
+
let c = null;
|
|
697
|
+
try {
|
|
698
|
+
const { startupPageCompletion } = await import('./tools.js');
|
|
699
|
+
c = startupPageCompletion(loadId);
|
|
700
|
+
} catch { return null; }
|
|
701
|
+
if (!c) {
|
|
702
|
+
decision = unknownLoadIdDecision(loadId);
|
|
703
|
+
startupPages = { checked: false, load_id: echoId(loadId), reason: 'no record of that load' };
|
|
704
|
+
} else {
|
|
705
|
+
if (c.complete || !Array.isArray(c.missing) || !c.missing.length) return null;
|
|
706
|
+
decision = startupPagesDecision(c);
|
|
707
|
+
startupPages = { load_id: c.load_id, of: c.of, missing: c.missing };
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const graph = { mode, base, highest: null, decision, startup_pages: startupPages };
|
|
711
|
+
const { ValidationError } = await import('./validation.js');
|
|
712
|
+
// The reason already names the exact calls that pass; no "Next:" repeat, so
|
|
713
|
+
// the message stays inside the 500-character error cap.
|
|
714
|
+
const err = new ValidationError('session_graph', decision.reason);
|
|
715
|
+
err.graph = graph; // so the lab records the refusal too
|
|
716
|
+
throw err;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Before a governed call: read the state, decide. Returns
|
|
721
|
+
* {mode, decision, base} or null when the graph does not apply.
|
|
722
|
+
* Throws a ValidationError when the decision is a refusal.
|
|
723
|
+
*/
|
|
724
|
+
export async function graphBefore(tool, args, dbManager, now = new Date()) {
|
|
725
|
+
const mode = graphMode();
|
|
726
|
+
if (tool === STARTUP_PAGES_TOOL) return startupPagesGate(args, now);
|
|
727
|
+
if (!GRAPH_WRITE_TOOLS.has(tool)) return null;
|
|
728
|
+
const sid = sessionOf(args);
|
|
729
|
+
let base = null; let highest = null; let decision;
|
|
730
|
+
if (!sid) {
|
|
731
|
+
// A session that is not open is not fully initialized: read access, and a
|
|
732
|
+
// new drop, are the only writes. Everything else still refuses.
|
|
733
|
+
if (tool === 'write_doc' && typeof args.doc_id === 'string' && args.doc_id.startsWith('drops/')) {
|
|
734
|
+
return { mode, base: null, highest: 0, decision: { verdict: 'ok', gate: 'drop_only', reason: null, next: 'open a session to write anything other than a new drop' } };
|
|
735
|
+
}
|
|
736
|
+
decision = noSessionDecision();
|
|
737
|
+
} else {
|
|
738
|
+
base = baseOf(sid);
|
|
739
|
+
const col = await sessionsCollection(dbManager);
|
|
740
|
+
let state = (await col.findOne({ _id: base })) || {};
|
|
741
|
+
// A transcript id is grouped as the close check groups it (<base>_extra
|
|
742
|
+
// belongs to <base>), so a closed session is found under any of its ids.
|
|
743
|
+
if (TRANSCRIPT_WRITE_TOOLS.has(tool) && !state.closed) {
|
|
744
|
+
for (const b of candidateBases(sid)) {
|
|
745
|
+
if (b === base) continue;
|
|
746
|
+
const r = await col.findOne({ _id: b });
|
|
747
|
+
if (r && r.closed) { state = r; base = b; break; }
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
const pos = typeof dbManager.transcriptPosition === 'function' ? await dbManager.transcriptPosition(sid) : { highest: 0 };
|
|
751
|
+
highest = pos.highest || 0;
|
|
752
|
+
// A transcript write into a closed session is decided by closedSessionGuard,
|
|
753
|
+
// which always runs next and says why (the closing exchange already saved,
|
|
754
|
+
// its window passed, or which exchange could still close it).
|
|
755
|
+
decision = state.closed && TRANSCRIPT_WRITE_TOOLS.has(tool)
|
|
756
|
+
? { verdict: 'ok', gate: null, reason: null, next: CLOSED_NEXT }
|
|
757
|
+
: evaluate(tool, args, state, highest, now);
|
|
758
|
+
}
|
|
759
|
+
if (decision && decision.verdict === 'refuse') {
|
|
760
|
+
const { ValidationError } = await import('./validation.js');
|
|
761
|
+
const said = /nothing was written/i.test(decision.reason || '');
|
|
762
|
+
const err = new ValidationError('session_graph', `${decision.reason}${said ? '' : ' Nothing was written.'} Next: ${decision.next}.`);
|
|
763
|
+
err.graph = { mode, base, highest, decision }; // so the lab records the refusal too
|
|
764
|
+
throw err;
|
|
765
|
+
}
|
|
766
|
+
return { mode, base, highest, decision };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ---------------------------------------------------------------------------
|
|
770
|
+
// Lab results (2026-09-29, the owner: "a 'lab results' location for storing the
|
|
771
|
+
// results in real time with time stamps so we can have a resource to look at
|
|
772
|
+
// after the probationary period"). Every check the monorail makes - ok,
|
|
773
|
+
// refuse, unknown (would_refuse in rows from before enforce-only) - is one timestamped row in
|
|
774
|
+
// `<prefix>graph_lab`, written as it happens. emet_gaps reads the summary.
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
export const LAB_SUFFIX = 'graph_lab';
|
|
778
|
+
|
|
779
|
+
/** One lab row, pure. `graph` is what graphBefore returned (or attached to its refusal). */
|
|
780
|
+
export function labRow(tool, args = {}, graph = null, now = new Date(), actor = {}) {
|
|
781
|
+
const block = graphBlock(graph);
|
|
782
|
+
if (!block) return null;
|
|
783
|
+
const d = graph.decision;
|
|
784
|
+
return {
|
|
785
|
+
ts: now,
|
|
786
|
+
session: graph.base || baseOf(sessionOf(args)),
|
|
787
|
+
tool,
|
|
788
|
+
// writeSourceOf reads source and its aliases; save_to_layer and
|
|
789
|
+
// revise_memory carry the tag in metadata.source (2026-10-03 lab fix).
|
|
790
|
+
source: writeSourceOf(args) ?? (args.metadata && typeof args.metadata.source === 'string' && args.metadata.source.trim() ? args.metadata.source.trim() : null),
|
|
791
|
+
person_id: actor.personId || null,
|
|
792
|
+
client_id: actor.clientId || 'stdio',
|
|
793
|
+
exchange_declared: declaredExchange(tool, args),
|
|
794
|
+
highest_appended: Number.isInteger(graph.highest) ? graph.highest : null,
|
|
795
|
+
mode: block.mode,
|
|
796
|
+
verdict: block.verdict,
|
|
797
|
+
gate: d.gate || null,
|
|
798
|
+
reason: d.reason || null,
|
|
799
|
+
...(tool === 'write_doc' || tool === 'patch_doc' ? { doc_id: args.doc_id || null } : {}),
|
|
800
|
+
...(graph.startup_pages ? { startup_pages: graph.startup_pages } : {})
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Write the lab row. Never throws and never blocks the call: a lost row is logged. */
|
|
805
|
+
export async function labRecord(tool, args, graph, dbManager, logger = null) {
|
|
806
|
+
try {
|
|
807
|
+
const { currentActor } = await import('./persons.js');
|
|
808
|
+
const row = labRow(tool, args, graph, new Date(), currentActor());
|
|
809
|
+
if (!row) return;
|
|
810
|
+
const { COLLECTION_PREFIX } = await import('./database.js');
|
|
811
|
+
const db = await dbManager.ensureClient();
|
|
812
|
+
const lab = db.collection(`${COLLECTION_PREFIX}${LAB_SUFFIX}`);
|
|
813
|
+
try { await lab.createIndex({ client_id: 1 }, { sparse: true }); } catch { /* an index miss never blocks the row */ }
|
|
814
|
+
await lab.insertOne(row);
|
|
815
|
+
} catch (e) {
|
|
816
|
+
logger?.warn?.('session graph: lab row not recorded', { tool, error: e && e.message });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Startup pages fetched after the session opened without them (2026-10-03):
|
|
822
|
+
* one lab row each, verdict `late`, gate `startup_pages`. Inserted, never an
|
|
823
|
+
* update of the open's row; labSummary pairs them up so a page fetched late
|
|
824
|
+
* is not counted as still missing. Never throws, never awaited by a page.
|
|
825
|
+
*/
|
|
826
|
+
export function recordLatePages(late = [], dbManager, logger = null) {
|
|
827
|
+
if (!Array.isArray(late) || !late.length || !dbManager) return;
|
|
828
|
+
for (const e of late) {
|
|
829
|
+
const graph = {
|
|
830
|
+
mode: graphMode(), base: e.session, highest: null,
|
|
831
|
+
decision: { verdict: 'late', gate: 'startup_pages',
|
|
832
|
+
reason: `startup page ${e.page} of ${e.of} (load ${e.load_id}) was fetched after the session opened: late, not missing`, next: null },
|
|
833
|
+
startup_pages: { load_id: e.load_id, of: e.of, page: e.page, late: true }
|
|
834
|
+
};
|
|
835
|
+
labRecord('emet_initialize', {}, graph, dbManager, logger);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* The lab summary emet_gaps serves, from a list of rows (pure, so it is
|
|
841
|
+
* tested without a database). Totals by verdict, gate, source and tool; the
|
|
842
|
+
* flagged rows (anything not ok) newest first, up to `limit`.
|
|
843
|
+
*/
|
|
844
|
+
export function labSummary(rows = [], { limit = 50 } = {}) {
|
|
845
|
+
const count = (key) => rows.reduce((m, r) => { const k = r[key] ?? 'none'; m[k] = (m[k] || 0) + 1; return m; }, {});
|
|
846
|
+
// Startup pages (2026-10-03): a page fetched after the open (a `late` row)
|
|
847
|
+
// is not still missing. An open whose every missing page came late is not
|
|
848
|
+
// flagged; a `late` row is never flagged; an enforced refusal stays flagged.
|
|
849
|
+
const sp = (r) => (r.gate === 'startup_pages' && r.startup_pages ? r.startup_pages : null);
|
|
850
|
+
const lateKeys = new Set(rows.filter((r) => sp(r) && r.verdict === 'late').map((r) => `${r.session}|${r.startup_pages.load_id}|${r.startup_pages.page}`));
|
|
851
|
+
const stillMissing = (r) => (sp(r) && Array.isArray(r.startup_pages.missing)
|
|
852
|
+
? r.startup_pages.missing.filter((p) => !lateKeys.has(`${r.session}|${r.startup_pages.load_id}|${p}`)) : null);
|
|
853
|
+
const settled = (r) => r.verdict === 'late' || (r.verdict === 'would_refuse' && sp(r) && stillMissing(r) && stillMissing(r).length === 0);
|
|
854
|
+
const flagged = rows.filter((r) => r.verdict !== 'ok' && !settled(r)).sort((a, b) => new Date(b.ts) - new Date(a.ts));
|
|
855
|
+
const opens = rows.filter((r) => sp(r) && r.tool === STARTUP_PAGES_TOOL && Array.isArray(r.startup_pages.missing));
|
|
856
|
+
const unchecked = rows.filter((r) => sp(r) && r.tool === STARTUP_PAGES_TOOL && r.startup_pages.checked === false);
|
|
857
|
+
const times = rows.map((r) => new Date(r.ts).getTime()).filter(Number.isFinite);
|
|
858
|
+
return {
|
|
859
|
+
location: `<prefix>${LAB_SUFFIX}`,
|
|
860
|
+
checks: rows.length,
|
|
861
|
+
first_at: times.length ? new Date(Math.min(...times)).toISOString() : null,
|
|
862
|
+
last_at: times.length ? new Date(Math.max(...times)).toISOString() : null,
|
|
863
|
+
sessions: new Set(rows.map((r) => r.session).filter(Boolean)).size,
|
|
864
|
+
by_verdict: count('verdict'),
|
|
865
|
+
by_gate: count('gate'),
|
|
866
|
+
by_source: count('source'),
|
|
867
|
+
flagged: flagged.length,
|
|
868
|
+
flagged_rows: flagged.slice(0, limit).map((r) => ({
|
|
869
|
+
ts: new Date(r.ts).toISOString(), session: r.session, source: r.source, tool: r.tool,
|
|
870
|
+
verdict: r.verdict, gate: r.gate, exchange_declared: r.exchange_declared,
|
|
871
|
+
highest_appended: r.highest_appended, reason: r.reason,
|
|
872
|
+
...(r.doc_id !== undefined ? { doc_id: r.doc_id } : {}),
|
|
873
|
+
...(r.startup_pages ? { startup_pages: { ...r.startup_pages, ...(stillMissing(r) ? { still_missing: stillMissing(r) } : {}) } } : {})
|
|
874
|
+
})),
|
|
875
|
+
...(opens.length || lateKeys.size || unchecked.length ? { startup_pages: {
|
|
876
|
+
opens_refused_no_load_id: unchecked.filter((r) => r.verdict === 'refuse').length,
|
|
877
|
+
opens_with_missing: opens.filter((r) => r.verdict === 'would_refuse').length,
|
|
878
|
+
opens_refused: opens.filter((r) => r.verdict === 'refuse').length,
|
|
879
|
+
pages_still_missing: opens.filter((r) => r.verdict === 'would_refuse').reduce((n, r) => n + stillMissing(r).length, 0),
|
|
880
|
+
pages_late: lateKeys.size
|
|
881
|
+
} } : {}),
|
|
882
|
+
reading:
|
|
883
|
+
'Each flagged row is either a real skipped step that was refused (the monorail working) or a gate that is wrong (fix it). ' +
|
|
884
|
+
'Rows with verdict would_refuse are from before enforce-only (2026-10-03), when those writes were saved and flagged.'
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** The block a governed write's result carries. */
|
|
889
|
+
export function graphBlock(g) {
|
|
890
|
+
if (!g || !g.decision) return null;
|
|
891
|
+
const d = g.decision;
|
|
892
|
+
return {
|
|
893
|
+
mode: g.mode,
|
|
894
|
+
verdict: d.verdict,
|
|
895
|
+
...(d.gate ? { gate: d.gate } : {}),
|
|
896
|
+
...(d.reason ? { reason: d.reason } : {}),
|
|
897
|
+
next: d.next
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* After a successful call: record the step. Never throws - a failed record is
|
|
903
|
+
* logged, not fatal. Returns what happened, so the close can say so
|
|
904
|
+
* (2026-10-01, the second reader's nit 2 on an earlier draft, the reviewer's condition 2):
|
|
905
|
+
* null nothing to record (no session, or no step)
|
|
906
|
+
* {recorded: true, base} the step was written
|
|
907
|
+
* {recorded: false, base, error} the write failed (logged as a warning)
|
|
908
|
+
* The write is an idempotent $set, so a caller may simply call it again.
|
|
909
|
+
*/
|
|
910
|
+
export async function graphAfter(tool, args, data, dbManager, logger = null, now = new Date()) {
|
|
911
|
+
let base = null;
|
|
912
|
+
try {
|
|
913
|
+
base = tool === 'emet_session_open' ? (data && data.base) : baseOf(sessionOf(args));
|
|
914
|
+
if (!base) return null;
|
|
915
|
+
const set = recordStep(tool, args, data, now);
|
|
916
|
+
if (!set) return null;
|
|
917
|
+
const col = await sessionsCollection(dbManager);
|
|
918
|
+
const { currentPersonId, currentClientId } = await import('./persons.js');
|
|
919
|
+
const listed = tool === 'emet_session_open' && data && Array.isArray(data.drops_seen) ? data.drops_seen.filter((id) => typeof id === 'string') : [];
|
|
920
|
+
const opened = tool === 'emet_session_open'
|
|
921
|
+
? { opened_by: { person_id: currentPersonId() || null, client_id: currentClientId() || 'stdio' } }
|
|
922
|
+
: {};
|
|
923
|
+
// Merge, never overwrite: a reopen keeps drops already stamped.
|
|
924
|
+
// An unverified boot is stored as a flag, never as an empty seen-list.
|
|
925
|
+
// A verified empty list is a loaded list. $addToSet cannot store [], so
|
|
926
|
+
// leaving the field unset made the board guard treat "no unhandled drops"
|
|
927
|
+
// as "the list never loaded". Stamp [] only when the field is still
|
|
928
|
+
// missing (a fresh open, or a same-day reopen of a row that never recorded
|
|
929
|
+
// one). A failed load still stores the flag and no list.
|
|
930
|
+
const addDrops = tool === 'emet_session_open' && listed.length ? { $addToSet: { drops_seen: { $each: listed } } } : {};
|
|
931
|
+
const unverified = tool === 'emet_session_open' && data && data.drops_unverified === true ? { drops_unverified: true } : {};
|
|
932
|
+
const verifiedOpen = tool === 'emet_session_open' && data && data.drops_unverified !== true && Array.isArray(data.drops_seen);
|
|
933
|
+
const clearUnverified = verifiedOpen ? { $unset: { drops_unverified: '' } } : {};
|
|
934
|
+
await col.updateOne({ _id: base }, { $set: { ...set, ...unverified, updated_at: now }, $setOnInsert: { created_at: now, ...opened }, ...addDrops, ...clearUnverified }, { upsert: true });
|
|
935
|
+
if (verifiedOpen && listed.length === 0) {
|
|
936
|
+
await col.updateOne({ _id: base, drops_seen: { $exists: false } }, { $set: { drops_seen: [] } });
|
|
937
|
+
}
|
|
938
|
+
return { recorded: true, base };
|
|
939
|
+
} catch (e) {
|
|
940
|
+
logger?.warn?.('session graph: step not recorded', { tool, error: e && e.message });
|
|
941
|
+
return { recorded: false, base, error: (e && e.message) || String(e) };
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export default { GRAPH_MODES, graphMode, ID_ECHO_MAX, echoId, pageRanges, PAGE_LIST_MAX, unknownLoadIdDecision, GRAPH_WRITE_TOOLS, STARTUP_PAGES_TOOL, noLoadIdDecision, startupPagesDecision, startupPagesGate, recordLatePages, botRecordDir, botHandoffDoc, isBotTag, mainRecordIds, writeSourceOf, isBotHandoffWrite, baseOf, candidateBases, groupedUnder, isMintedPartOf, sessionOf, normalizeExchangeNumber, declaredExchange, CLOSED_NEXT, nextStep, evaluate, recordStep, graphBefore, graphBlock, graphAfter, sessionStatus, isSessionClosed, DEFAULT_CLOSE_GRACE_MINUTES, MAX_CLOSE_GRACE_MINUTES, closeGraceSetting, closeGraceMinutes, graceDecision, markCloseGrace, TRANSCRIPT_WRITE_TOOLS, closedSessionGuard, LAB_SUFFIX, labRow, labRecord, labSummary };
|