faberun 0.3.0
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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
} from "node:fs";
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { writeJsonAtomic } from "../run/store.mjs";
|
|
10
|
+
import { requireId, requirePacketHash, requireString, requireTimestamp } from "../contract/assert.mjs";
|
|
11
|
+
import { promoteRun } from "../repo/integrate.mjs";
|
|
12
|
+
import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
|
|
13
|
+
import { readCampaign } from "./record.mjs";
|
|
14
|
+
import { appendJournal, normalizeText, readJournalForDedupe } from "./journal.mjs";
|
|
15
|
+
import { readProjectionState } from "./projection.mjs";
|
|
16
|
+
import { handoffFromState, materializeHandoff } from "./handoff.mjs";
|
|
17
|
+
|
|
18
|
+
/** @typedef {Record<string, unknown>} JsonObject */
|
|
19
|
+
/** @typedef {{path: string, digest: string}} CampaignContract */
|
|
20
|
+
/** @typedef {{runId: string, contractPath?: string, branch: string, sha: string, previousSha: string|null, at: string}} PromotionRecord */
|
|
21
|
+
/** @typedef {{code: string, message: string, at: string, contractPath?: string, contractId?: string, runId?: string, node?: string|null, status?: string|null, resume?: string}} CampaignAttention */
|
|
22
|
+
/** @typedef {{id: string, goal: string, status: "active"|"closed", linkedRunIds: string[], contracts: CampaignContract[], landBranch: string, promotions: PromotionRecord[], attention?: CampaignAttention, createdAt: string, updatedAt: string, closedAt?: string}} Campaign */
|
|
23
|
+
/** @typedef {{type: string, eventId: string, at: string, sessionId?: string, text?: string, tool?: string, transcript?: string|null, transcriptUnavailable?: boolean, format?: string|null, cursor?: string|null, decisionId?: string, supersedes?: string, runId?: string, questionId?: string, campaignId?: string, nodeId?: string|null, phase?: string, checkpointsDone?: number, checkpointsTotal?: number, runtime?: string|null, state?: string, lastProgressAt?: string, attention?: string|null}} JournalEntry */
|
|
24
|
+
/** @typedef {{updatedAt: string|null, decisions: Record<string, JournalEntry>, questions: Record<string, JournalEntry>, constraints: JournalEntry[], intents: JournalEntry[], outcomes: JournalEntry[], sessions: JournalEntry[], next: JournalEntry|null, evicted: Record<string, number>}} Projection */
|
|
25
|
+
/** @typedef {{cursor: number, byte: number, size: number, projection: Projection}} ProjectionRecord */
|
|
26
|
+
/** @typedef {{id: string, exists: boolean, total: number, summary: string, attention: {id: string, status: string, note: string}[], unreadable: string|null}} RunSummary */
|
|
27
|
+
/** @typedef {{campaign: Campaign, updatedAt: string, linkedRuns: RunSummary[], activeDecisions: JournalEntry[], constraints: JournalEntry[], intents: JournalEntry[], outcomes: JournalEntry[], nextEntry: JournalEntry|null, questions: JournalEntry[], sessions: JournalEntry[], totals: {decisions: number, constraints: number, intents: number, outcomes: number, questions: number, sessions: number}, evicted: Record<string, number>}} Handoff */
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} runsDir
|
|
31
|
+
* @param {{campaignId: string, goal: unknown, at?: string, contracts?: CampaignContract[], landBranch?: string}} options
|
|
32
|
+
* @returns {{path: string, campaign: Campaign}}
|
|
33
|
+
*/
|
|
34
|
+
export function initializeCampaign(runsDir, { campaignId, goal, at = new Date().toISOString(), contracts = [], landBranch = undefined }) {
|
|
35
|
+
requireId(campaignId, "campaignId");
|
|
36
|
+
requireTimestamp(at, "at");
|
|
37
|
+
const path = campaignDir(runsDir, campaignId);
|
|
38
|
+
if (existsSync(path)) throw new Error(`campaign already exists: ${path}`);
|
|
39
|
+
mkdirSync(path, { recursive: true });
|
|
40
|
+
const validatedContracts = contracts.map((entry, index) => {
|
|
41
|
+
requireString(entry.path, `contracts[${index}].path`);
|
|
42
|
+
requirePacketHash(entry.digest, `contracts[${index}].digest`);
|
|
43
|
+
return { path: entry.path, digest: entry.digest };
|
|
44
|
+
});
|
|
45
|
+
const branch = landBranch ?? `campaign/${campaignId}`;
|
|
46
|
+
requireString(branch, "landBranch");
|
|
47
|
+
/** @type {Campaign} */
|
|
48
|
+
const campaign = {
|
|
49
|
+
id: campaignId,
|
|
50
|
+
goal: normalizeText(goal, "goal", GOAL_TEXT_BYTES),
|
|
51
|
+
status: "active",
|
|
52
|
+
linkedRunIds: [],
|
|
53
|
+
contracts: validatedContracts,
|
|
54
|
+
landBranch: branch,
|
|
55
|
+
promotions: [],
|
|
56
|
+
createdAt: at,
|
|
57
|
+
updatedAt: at,
|
|
58
|
+
};
|
|
59
|
+
writeJsonAtomic(join(path, CAMPAIGN_FILE), campaign);
|
|
60
|
+
appendJournal(path, { type: "campaign.initialized", at, eventId: randomUUID() });
|
|
61
|
+
return { path, campaign };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} runsDir
|
|
66
|
+
* @returns {{campaigns: {path: string, campaign: Campaign}[], corrupt: {id: string, path: string, error: Error}[]}}
|
|
67
|
+
*/
|
|
68
|
+
export function discoverCampaigns(runsDir) {
|
|
69
|
+
const root = campaignsDir(runsDir);
|
|
70
|
+
if (!existsSync(root)) return { campaigns: [], corrupt: [] };
|
|
71
|
+
/** @type {{path: string, campaign: Campaign}[]} */
|
|
72
|
+
const campaigns = [];
|
|
73
|
+
/** @type {{id: string, path: string, error: Error}[]} */
|
|
74
|
+
const corrupt = [];
|
|
75
|
+
for (const name of readdirSync(root, { withFileTypes: true })
|
|
76
|
+
.filter((entry) => entry.isDirectory())
|
|
77
|
+
.map((entry) => entry.name)
|
|
78
|
+
.sort()) {
|
|
79
|
+
const path = join(root, name);
|
|
80
|
+
if (!existsSync(join(path, CAMPAIGN_FILE))) {
|
|
81
|
+
corrupt.push({ id: name, path, error: new Error(`campaign.json missing in ${path}`) });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
campaigns.push({ path, campaign: readCampaign(path) });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
corrupt.push({ id: name, path, error: error instanceof Error ? error : new Error(String(error)) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { campaigns, corrupt };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} runsDir
|
|
95
|
+
* @param {string|null|undefined} [campaignId]
|
|
96
|
+
* @returns {{path: string, campaign: Campaign}}
|
|
97
|
+
*/
|
|
98
|
+
export function resolveCampaign(runsDir, campaignId) {
|
|
99
|
+
if (campaignId !== undefined && campaignId !== null) {
|
|
100
|
+
requireId(campaignId, "campaignId");
|
|
101
|
+
const path = campaignDir(runsDir, campaignId);
|
|
102
|
+
return { path, campaign: readCampaign(path) };
|
|
103
|
+
}
|
|
104
|
+
const { campaigns, corrupt } = discoverCampaigns(runsDir);
|
|
105
|
+
if (corrupt.length) {
|
|
106
|
+
throw new Error(`corrupt campaign entries: ${corrupt.map((entry) => entry.id).join(", ")}`);
|
|
107
|
+
}
|
|
108
|
+
const active = campaigns.filter((entry) => entry.campaign.status === "active");
|
|
109
|
+
if (!active.length) {
|
|
110
|
+
if (campaigns.length) {
|
|
111
|
+
throw new Error(`no active campaign under ${campaignsDir(runsDir)}; all campaigns are closed`);
|
|
112
|
+
}
|
|
113
|
+
throw new Error(`no campaign found under ${campaignsDir(runsDir)}; initialize one with: runner.mjs campaign init`);
|
|
114
|
+
}
|
|
115
|
+
if (active.length > 1) {
|
|
116
|
+
const ids = active.map((entry) => entry.campaign.id).join(", ");
|
|
117
|
+
throw new Error(`multiple campaigns found (${ids}); choose one by id`);
|
|
118
|
+
}
|
|
119
|
+
return active[0];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} campaignPath
|
|
124
|
+
* @param {{at?: string, eventId?: string}} options
|
|
125
|
+
* @returns {{path: string, campaign: Campaign}}
|
|
126
|
+
*/
|
|
127
|
+
export function closeCampaign(campaignPath, { at = new Date().toISOString(), eventId = randomUUID() } = {}) {
|
|
128
|
+
requireTimestamp(at, "at");
|
|
129
|
+
const campaign = readCampaign(campaignPath);
|
|
130
|
+
if (campaign.status === "closed") throw new Error(`campaign already closed: ${campaign.id}`);
|
|
131
|
+
if (!readJournalForDedupe(campaignPath).some((entry) => entry.type === "retrospective")) {
|
|
132
|
+
throw new Error(`campaign ${campaign.id} has no recorded retrospective; record one with note --kind retrospective before close`);
|
|
133
|
+
}
|
|
134
|
+
const closed = /** @type {Campaign} */ ({ ...campaign, status: "closed", closedAt: at, updatedAt: at });
|
|
135
|
+
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), closed);
|
|
136
|
+
appendJournal(campaignPath, { type: "campaign.closed", at, eventId });
|
|
137
|
+
return { path: campaignPath, campaign: closed };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {string} campaignPath
|
|
142
|
+
* @param {string} runId
|
|
143
|
+
* @param {string} at
|
|
144
|
+
* @returns {Campaign}
|
|
145
|
+
*/
|
|
146
|
+
export function registerRun(campaignPath, runId, at = new Date().toISOString()) {
|
|
147
|
+
requireId(runId, "runId");
|
|
148
|
+
requireTimestamp(at, "at");
|
|
149
|
+
const campaign = readCampaign(campaignPath);
|
|
150
|
+
if (campaign.status === "closed") throw new Error(`campaign is closed: ${campaign.id}`);
|
|
151
|
+
if (campaign.linkedRunIds.includes(runId)) {
|
|
152
|
+
campaign.updatedAt = at;
|
|
153
|
+
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), campaign);
|
|
154
|
+
return campaign;
|
|
155
|
+
}
|
|
156
|
+
campaign.linkedRunIds.push(runId);
|
|
157
|
+
campaign.updatedAt = at;
|
|
158
|
+
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), campaign);
|
|
159
|
+
appendJournal(campaignPath, { type: "run.registered", at, eventId: randomUUID(), runId });
|
|
160
|
+
return campaign;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The digest of a contract's authored bytes: the raw file, before validation
|
|
165
|
+
* canonicalizes or resolves anything. This is what a manifest entry records,
|
|
166
|
+
* so tampering between authoring and launch is detectable without validating
|
|
167
|
+
* the contract early (validation is deferred because a readFile may name a
|
|
168
|
+
* file a predecessor has not created yet).
|
|
169
|
+
*
|
|
170
|
+
* @param {string} contractPath
|
|
171
|
+
* @returns {string}
|
|
172
|
+
*/
|
|
173
|
+
export function authoredContractDigest(contractPath) {
|
|
174
|
+
return createHash("sha256").update(readFileSync(contractPath)).digest("hex");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Refuse a manifest entry whose file no longer matches the bytes it recorded.
|
|
179
|
+
* The chain calls this at launch, before it validates N+1 against the landing
|
|
180
|
+
* branch.
|
|
181
|
+
*
|
|
182
|
+
* @param {CampaignContract} entry
|
|
183
|
+
* @returns {void}
|
|
184
|
+
*/
|
|
185
|
+
export function assertContractManifestIntact(entry) {
|
|
186
|
+
requireString(entry.path, "contract path");
|
|
187
|
+
requirePacketHash(entry.digest, "contract digest");
|
|
188
|
+
const actual = authoredContractDigest(entry.path);
|
|
189
|
+
if (actual !== entry.digest) {
|
|
190
|
+
throw Object.assign(
|
|
191
|
+
new Error(`contract ${entry.path} changed after it was authored; refusing to launch bytes the manifest did not record`),
|
|
192
|
+
{ code: "contract_authored_bytes_changed" },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Persist one promotion in the campaign record. Idempotent by run id and the
|
|
199
|
+
* sha it landed: a re-invocation after a crash between the branch move and
|
|
200
|
+
* this write repairs the record without adding a second promotion.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} campaignPath
|
|
203
|
+
* @param {PromotionRecord} entry
|
|
204
|
+
* @returns {PromotionRecord}
|
|
205
|
+
*/
|
|
206
|
+
export function recordPromotion(campaignPath, entry) {
|
|
207
|
+
requireId(entry.runId, "promotion.runId");
|
|
208
|
+
requireString(entry.branch, "promotion.branch");
|
|
209
|
+
requireString(entry.sha, "promotion.sha");
|
|
210
|
+
requireTimestamp(entry.at, "promotion.at");
|
|
211
|
+
const campaign = readCampaign(campaignPath);
|
|
212
|
+
const existing = campaign.promotions.find((record) => record.runId === entry.runId && record.sha === entry.sha);
|
|
213
|
+
if (existing) return existing;
|
|
214
|
+
campaign.promotions.push(entry);
|
|
215
|
+
campaign.updatedAt = entry.at;
|
|
216
|
+
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), campaign);
|
|
217
|
+
return entry;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Record a durable attention entry on the campaign and keep it active. The
|
|
222
|
+
* chain parks here when a contract's run did not succeed, when a contract no
|
|
223
|
+
* longer validates, or when two digests disagree; the message names the
|
|
224
|
+
* contract, the node and the status whenever they exist.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} campaignPath
|
|
227
|
+
* @param {CampaignAttention} attention
|
|
228
|
+
* @returns {Campaign}
|
|
229
|
+
*/
|
|
230
|
+
export function parkCampaign(campaignPath, attention) {
|
|
231
|
+
requireString(attention.code, "campaign.attention.code");
|
|
232
|
+
requireString(attention.message, "campaign.attention.message");
|
|
233
|
+
requireTimestamp(attention.at, "campaign.attention.at");
|
|
234
|
+
const campaign = readCampaign(campaignPath);
|
|
235
|
+
const parked = /** @type {Campaign} */ ({ ...campaign, attention, updatedAt: attention.at });
|
|
236
|
+
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), parked);
|
|
237
|
+
return parked;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Promote a run onto the campaign's landing branch and record it. The branch
|
|
242
|
+
* name comes from the campaign record, never from the caller, so a campaign
|
|
243
|
+
* cannot be promoted somewhere its manifest does not name.
|
|
244
|
+
*
|
|
245
|
+
* @param {{campaignPath: string, repo: string, runId: string, runHead?: string|null, baseSha?: string|null, finalVerificationPassed: boolean, allowMain?: boolean, contractPath?: string}} args
|
|
246
|
+
* @returns {import("../repo/integrate.mjs").PromoteRecord}
|
|
247
|
+
*/
|
|
248
|
+
export function promoteRunInCampaign({ campaignPath, repo, runId, runHead, baseSha, finalVerificationPassed, allowMain = false, contractPath }) {
|
|
249
|
+
const campaign = readCampaign(campaignPath);
|
|
250
|
+
if (campaign.status === "closed") throw new Error(`campaign is closed: ${campaign.id}`);
|
|
251
|
+
return promoteRun({
|
|
252
|
+
repo,
|
|
253
|
+
runId,
|
|
254
|
+
landBranch: campaign.landBranch,
|
|
255
|
+
runHead,
|
|
256
|
+
baseSha,
|
|
257
|
+
finalVerificationPassed,
|
|
258
|
+
allowMain,
|
|
259
|
+
onPromoted: (record) => {
|
|
260
|
+
recordPromotion(campaignPath, {
|
|
261
|
+
runId: record.runId,
|
|
262
|
+
...(contractPath === undefined ? {} : { contractPath }),
|
|
263
|
+
branch: record.branch,
|
|
264
|
+
sha: record.sha,
|
|
265
|
+
previousSha: record.previousSha,
|
|
266
|
+
at: record.at,
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {string} campaignPath
|
|
274
|
+
* @param {string} runsDir
|
|
275
|
+
* @returns {string}
|
|
276
|
+
*/
|
|
277
|
+
export function renderHandoff(campaignPath, runsDir) {
|
|
278
|
+
const campaign = readCampaign(campaignPath);
|
|
279
|
+
const { state, cursor, byte, size, changed } = readProjectionState(campaignPath, campaign);
|
|
280
|
+
const handoff = handoffFromState(campaign, state, runsDir);
|
|
281
|
+
const text = materializeHandoff(campaignPath, handoff);
|
|
282
|
+
if (changed) writeJsonAtomic(join(campaignPath, PROJECTION_FILE), { cursor, byte, size, projection: state });
|
|
283
|
+
return text;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {string} runDir
|
|
288
|
+
* @returns {string|null}
|
|
289
|
+
*/
|
|
290
|
+
export function renderRunHandoff(runDir) {
|
|
291
|
+
const contractPath = join(runDir, "contract.json");
|
|
292
|
+
if (!existsSync(contractPath)) return null;
|
|
293
|
+
const contract = /** @type {JsonObject} */ (JSON.parse(readFileSync(contractPath, "utf8")));
|
|
294
|
+
if (!contract.campaignId) return null;
|
|
295
|
+
const runsDir = resolve(runDir, "..");
|
|
296
|
+
const path = campaignDir(runsDir, /** @type {string} */ (contract.campaignId));
|
|
297
|
+
if (!existsSync(join(path, CAMPAIGN_FILE))) return null;
|
|
298
|
+
return renderHandoff(path, runsDir);
|
|
299
|
+
}
|
|
300
|
+
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The campaign journal: an append-only log of material events, and the cursor
|
|
3
|
+
* machinery that lets a session read only what it has not seen.
|
|
4
|
+
*
|
|
5
|
+
* Append-only is the point. A campaign is the durable memory across sessions and
|
|
6
|
+
* across agents, so the journal is never rewritten -- `watchJournal` and
|
|
7
|
+
* `acknowledgeJournalEvent` move a per-session cursor over it instead, keyed by
|
|
8
|
+
* the journal's own `eventId` so two sessions cannot consume each other's place.
|
|
9
|
+
*/
|
|
10
|
+
import { JOURNAL_FILE, JOURNAL_TEXT_BYTES, JOURNAL_WATCH_CURSOR_DIR, JOURNAL_WATCH_CURSOR_SCHEMA_VERSION } from "./layout.mjs";
|
|
11
|
+
import { boundedText, collapseLines } from "../util.mjs";
|
|
12
|
+
import { campaignIdOf } from "./record.mjs";
|
|
13
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
15
|
+
import { requireText, requireTimestamp } from "../contract/assert.mjs";
|
|
16
|
+
import { writeJsonAtomic } from "../run/store.mjs";
|
|
17
|
+
|
|
18
|
+
/** @typedef {import("./index.mjs").JournalEntry} JournalEntry */
|
|
19
|
+
/** @typedef {import("../notify/index.mjs").JsonObject} JsonObject */
|
|
20
|
+
|
|
21
|
+
const JOURNAL_TYPES = new Set([
|
|
22
|
+
"campaign.initialized",
|
|
23
|
+
"campaign.closed",
|
|
24
|
+
"campaign.unparked",
|
|
25
|
+
"run.registered",
|
|
26
|
+
"session.attached",
|
|
27
|
+
"intent",
|
|
28
|
+
"decision",
|
|
29
|
+
"supersede",
|
|
30
|
+
"constraint",
|
|
31
|
+
"outcome",
|
|
32
|
+
"next",
|
|
33
|
+
"open-question",
|
|
34
|
+
"question.resolved",
|
|
35
|
+
"retrospective",
|
|
36
|
+
"liveness",
|
|
37
|
+
]);
|
|
38
|
+
const SESSION_REQUIRED_TYPES = new Set([
|
|
39
|
+
"session.attached",
|
|
40
|
+
"intent",
|
|
41
|
+
"decision",
|
|
42
|
+
"supersede",
|
|
43
|
+
"constraint",
|
|
44
|
+
"outcome",
|
|
45
|
+
"next",
|
|
46
|
+
"open-question",
|
|
47
|
+
"question.resolved",
|
|
48
|
+
"retrospective",
|
|
49
|
+
]);
|
|
50
|
+
const ENTRY_SHAPES = {
|
|
51
|
+
"campaign.initialized": ["at", "type", "eventId"],
|
|
52
|
+
"campaign.closed": ["at", "type", "eventId"],
|
|
53
|
+
"campaign.unparked": ["at", "type", "eventId", "code", "runId"],
|
|
54
|
+
"run.registered": ["at", "type", "eventId", "runId"],
|
|
55
|
+
"session.attached": ["at", "type", "eventId", "sessionId", "tool", "transcript", "transcriptUnavailable", "format", "cursor"],
|
|
56
|
+
intent: ["at", "type", "eventId", "sessionId", "text"],
|
|
57
|
+
decision: ["at", "type", "eventId", "sessionId", "decisionId", "text"],
|
|
58
|
+
supersede: ["at", "type", "eventId", "sessionId", "supersedes", "text"],
|
|
59
|
+
constraint: ["at", "type", "eventId", "sessionId", "text"],
|
|
60
|
+
outcome: ["at", "type", "eventId", "sessionId", "text", "runId"],
|
|
61
|
+
next: ["at", "type", "eventId", "sessionId", "text"],
|
|
62
|
+
"open-question": ["at", "type", "eventId", "sessionId", "questionId", "text"],
|
|
63
|
+
"question.resolved": ["at", "type", "eventId", "sessionId", "questionId", "text"],
|
|
64
|
+
retrospective: ["at", "type", "eventId", "sessionId", "text"],
|
|
65
|
+
liveness: ["at", "type", "eventId", "campaignId", "runId", "nodeId", "phase", "checkpointsDone", "checkpointsTotal", "runtime", "state", "lastProgressAt", "attention"],
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Fields a liveness fact carried before the budget ceiling was removed. A
|
|
69
|
+
* historical journal (like the live campaign's own) still has lines shaped
|
|
70
|
+
* like the pre-diet fact, so a read path drops them instead of rejecting the
|
|
71
|
+
* whole file; nothing writes them any more.
|
|
72
|
+
*/
|
|
73
|
+
const LEGACY_LIVENESS_FIELDS = ["weightedUsed", "weightedCap"];
|
|
74
|
+
/**
|
|
75
|
+
* @param {JournalEntry} entry
|
|
76
|
+
* @returns {JournalEntry}
|
|
77
|
+
*/
|
|
78
|
+
export function withoutLegacyLivenessFields(entry) {
|
|
79
|
+
if (entry.type !== "liveness") return entry;
|
|
80
|
+
const cleaned = /** @type {JournalEntry} */ ({ ...entry });
|
|
81
|
+
for (const field of LEGACY_LIVENESS_FIELDS) delete /** @type {JsonObject} */ (cleaned)[field];
|
|
82
|
+
return cleaned;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} campaignPath
|
|
86
|
+
* @param {unknown} entry
|
|
87
|
+
* @returns {{entry: JournalEntry, deduplicated: boolean}}
|
|
88
|
+
*/
|
|
89
|
+
export function appendJournal(campaignPath, entry) {
|
|
90
|
+
validateJournalEntry(entry);
|
|
91
|
+
const normalized = normalizeEntry(/** @type {JournalEntry} */ (entry));
|
|
92
|
+
const journalPath = join(campaignPath, JOURNAL_FILE);
|
|
93
|
+
if (existsSync(journalPath)) {
|
|
94
|
+
for (const existing of readJournalForDedupe(campaignPath)) {
|
|
95
|
+
if (existing.eventId === normalized.eventId) return { entry: existing, deduplicated: true };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const descriptor = openSync(journalPath, "a");
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(descriptor, `${JSON.stringify(normalized)}\n`);
|
|
101
|
+
fsyncSync(descriptor);
|
|
102
|
+
} finally {
|
|
103
|
+
closeSync(descriptor);
|
|
104
|
+
}
|
|
105
|
+
return { entry: normalized, deduplicated: false };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* @param {string} campaignPath
|
|
109
|
+
* @returns {JournalEntry[]}
|
|
110
|
+
*/
|
|
111
|
+
export function readJournalForDedupe(campaignPath) {
|
|
112
|
+
const path = join(campaignPath, JOURNAL_FILE);
|
|
113
|
+
if (!existsSync(path)) return [];
|
|
114
|
+
/** @type {JournalEntry[]} */
|
|
115
|
+
const entries = [];
|
|
116
|
+
for (const line of readFileSync(path, "utf8").split(/\r?\n/u)) {
|
|
117
|
+
if (!line.trim()) continue;
|
|
118
|
+
try {
|
|
119
|
+
entries.push(/** @type {JournalEntry} */ (JSON.parse(line)));
|
|
120
|
+
} catch {
|
|
121
|
+
// A corrupt prior line cannot match an event id; appends must not be
|
|
122
|
+
// blocked by it because the journal is the authoritative audit source.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return entries;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Incremental read of the campaign journal for a session sync/watch, keyed by
|
|
129
|
+
* the journal's own `eventId`. Legacy `liveness` entries (the old heartbeat
|
|
130
|
+
* mechanism used to append them; nothing does any more) are filtered out: they carry no
|
|
131
|
+
* narrative a session needs to catch up on. Exactly one of `since` (a
|
|
132
|
+
* stateless event id) or `cursor` (a durable per-watcher position) is
|
|
133
|
+
* accepted; `cursor` advances atomically after the unseen list is built
|
|
134
|
+
* unless `readOnly` is set, so a repeated read-only call returns no events.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} campaignPath
|
|
137
|
+
* @param {{since?: string, cursor?: string, readOnly?: boolean}} [options]
|
|
138
|
+
* @returns {{campaignId: string, cursor: {cursorId: string, at: string, eventId: string}|null, events: JournalEntry[]}}
|
|
139
|
+
*/
|
|
140
|
+
export function watchJournal(campaignPath, options = {}) {
|
|
141
|
+
if ((options.since !== undefined) === (options.cursor !== undefined)) {
|
|
142
|
+
throw new TypeError("watch requires exactly one of --since or --cursor");
|
|
143
|
+
}
|
|
144
|
+
const entries = readJournal(campaignPath)
|
|
145
|
+
.filter((entry) => entry.type !== "liveness")
|
|
146
|
+
.sort(compareJournalPositions);
|
|
147
|
+
let cursorId = null;
|
|
148
|
+
/** @type {{at: string, eventId: string}} */
|
|
149
|
+
let position;
|
|
150
|
+
if (options.since !== undefined) {
|
|
151
|
+
const since = String(options.since);
|
|
152
|
+
const entry = entries.find((candidate) => candidate.eventId === since);
|
|
153
|
+
if (!entry) throw new TypeError("--since event ID is not retained in the journal");
|
|
154
|
+
position = { at: entry.at, eventId: entry.eventId };
|
|
155
|
+
} else {
|
|
156
|
+
cursorId = String(options.cursor);
|
|
157
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(cursorId)) throw new TypeError("--cursor must be a safe identifier");
|
|
158
|
+
position = readJournalCursor(campaignPath, cursorId);
|
|
159
|
+
}
|
|
160
|
+
const events = entries.filter((entry) => compareJournalPositions(entry, position) > 0);
|
|
161
|
+
if (cursorId !== null && !options.readOnly && events.length > 0) {
|
|
162
|
+
const last = events[events.length - 1];
|
|
163
|
+
position = { at: last.at, eventId: last.eventId };
|
|
164
|
+
writeJournalCursor(campaignPath, { cursorId, ...position });
|
|
165
|
+
}
|
|
166
|
+
return { campaignId: campaignIdOf(campaignPath), cursor: cursorId === null ? null : { cursorId, ...position }, events };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Acknowledge one retained journal event by atomically advancing a durable
|
|
170
|
+
* per-session cursor to it. `ack` is the only cursor writer: `watchJournal`
|
|
171
|
+
* only advances when explicitly told to (never in read-only mode). Cursor
|
|
172
|
+
* movement never regresses.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} campaignPath
|
|
175
|
+
* @param {string} cursorId
|
|
176
|
+
* @param {string} eventId
|
|
177
|
+
* @returns {{cursorId: string, at: string, eventId: string}}
|
|
178
|
+
*/
|
|
179
|
+
export function acknowledgeJournalEvent(campaignPath, cursorId, eventId) {
|
|
180
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(cursorId)) throw new TypeError("--cursor must be a safe identifier");
|
|
181
|
+
if (typeof eventId !== "string" || !eventId.trim()) throw new TypeError("--event-id requires a value");
|
|
182
|
+
const current = readJournalCursor(campaignPath, cursorId);
|
|
183
|
+
const found = readJournal(campaignPath).find((candidate) => candidate.eventId === eventId);
|
|
184
|
+
let position;
|
|
185
|
+
if (found) {
|
|
186
|
+
position = { at: found.at, eventId: found.eventId };
|
|
187
|
+
} else if (current.eventId !== "" && current.eventId === eventId) {
|
|
188
|
+
position = current;
|
|
189
|
+
} else {
|
|
190
|
+
throw new TypeError("--event-id is not retained in the journal");
|
|
191
|
+
}
|
|
192
|
+
if (current.eventId !== "" && compareJournalPositions(position, current) <= 0) position = current;
|
|
193
|
+
if (current.eventId === "" || compareJournalPositions(position, current) > 0) {
|
|
194
|
+
writeJournalCursor(campaignPath, { cursorId, ...position });
|
|
195
|
+
}
|
|
196
|
+
return { cursorId, ...position };
|
|
197
|
+
}
|
|
198
|
+
/** @param {{at: string, eventId: string}} left @param {{at: string, eventId: string}} right @returns {number} */
|
|
199
|
+
function compareJournalPositions(left, right) {
|
|
200
|
+
if (left.at !== right.at) return left.at < right.at ? -1 : 1;
|
|
201
|
+
return left.eventId < right.eventId ? -1 : left.eventId > right.eventId ? 1 : 0;
|
|
202
|
+
}
|
|
203
|
+
/** @param {string} campaignPath @param {string} cursorId @returns {{at: string, eventId: string}} */
|
|
204
|
+
function readJournalCursor(campaignPath, cursorId) {
|
|
205
|
+
const path = join(campaignPath, JOURNAL_WATCH_CURSOR_DIR, `${cursorId}.json`);
|
|
206
|
+
if (!existsSync(path)) return { at: "", eventId: "" };
|
|
207
|
+
const record = /** @type {JsonObject} */ (JSON.parse(readFileSync(path, "utf8")));
|
|
208
|
+
requireText(record.at, "journal watch cursor.at");
|
|
209
|
+
if (typeof record.eventId !== "string") throw new TypeError("journal watch cursor.eventId must be a string");
|
|
210
|
+
return { at: String(record.at), eventId: record.eventId };
|
|
211
|
+
}
|
|
212
|
+
/** @param {string} campaignPath @param {{cursorId: string, at: string, eventId: string}} position */
|
|
213
|
+
function writeJournalCursor(campaignPath, position) {
|
|
214
|
+
const path = join(campaignPath, JOURNAL_WATCH_CURSOR_DIR, `${position.cursorId}.json`);
|
|
215
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
216
|
+
writeJsonAtomic(path, { schemaVersion: JOURNAL_WATCH_CURSOR_SCHEMA_VERSION, ...position, updatedAt: new Date().toISOString() });
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* @param {string} campaignPath
|
|
220
|
+
* @returns {JournalEntry[]}
|
|
221
|
+
*/
|
|
222
|
+
export function readJournal(campaignPath) {
|
|
223
|
+
const path = join(campaignPath, JOURNAL_FILE);
|
|
224
|
+
if (!existsSync(path)) return [];
|
|
225
|
+
const text = readFileSync(path, "utf8");
|
|
226
|
+
const lines = text.split(/\r?\n/u);
|
|
227
|
+
/** @type {JournalEntry[]} */
|
|
228
|
+
const entries = [];
|
|
229
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
230
|
+
const line = lines[index];
|
|
231
|
+
if (!line.trim()) continue;
|
|
232
|
+
if (index === lines.length - 1 && !text.endsWith("\n")) {
|
|
233
|
+
// The newline is written atomically with the entry, so an unterminated
|
|
234
|
+
// final line is never a committed entry; skip it regardless of whether
|
|
235
|
+
// its bytes happen to parse.
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const entry = withoutLegacyLivenessFields(/** @type {JournalEntry} */ (JSON.parse(line)));
|
|
240
|
+
validateJournalEntry(entry);
|
|
241
|
+
entries.push(entry);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
throw new Error(`journal line ${index + 1} is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return entries;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* @param {unknown} entry
|
|
250
|
+
*/
|
|
251
|
+
export function validateJournalEntry(entry) {
|
|
252
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
253
|
+
throw new TypeError("journal entry must be an object");
|
|
254
|
+
}
|
|
255
|
+
const record = /** @type {JsonObject} */ (entry);
|
|
256
|
+
const type = /** @type {keyof typeof ENTRY_SHAPES} */ (record.type);
|
|
257
|
+
if (!JOURNAL_TYPES.has(type)) {
|
|
258
|
+
throw new TypeError(`journal entry type must be one of ${[...JOURNAL_TYPES].join(", ")}`);
|
|
259
|
+
}
|
|
260
|
+
const allowed = ENTRY_SHAPES[type];
|
|
261
|
+
for (const key of Object.keys(record)) {
|
|
262
|
+
if (!allowed.includes(key)) throw new TypeError(`journal entry ${type} has unexpected field ${key}`);
|
|
263
|
+
}
|
|
264
|
+
requireTimestamp(record.at, "entry.at");
|
|
265
|
+
requireText(record.eventId, "entry.eventId");
|
|
266
|
+
if (SESSION_REQUIRED_TYPES.has(type)) {
|
|
267
|
+
requireText(record.sessionId, "entry.sessionId");
|
|
268
|
+
}
|
|
269
|
+
// "liveness" is a legacy type: the old heartbeat mechanism used to append it and nothing
|
|
270
|
+
// writes it any more, but a durable journal may still carry old entries and
|
|
271
|
+
// reading them must not throw. The shared allowed-field check above already
|
|
272
|
+
// rejects an unexpected key; no deeper shape validation is needed for a type
|
|
273
|
+
// nothing produces.
|
|
274
|
+
if (type === "liveness") return;
|
|
275
|
+
if (type === "session.attached") return validateSessionEntry(record);
|
|
276
|
+
if (type === "run.registered") {
|
|
277
|
+
requireText(record.runId, "entry.runId");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (type === "campaign.initialized" || type === "campaign.closed") return;
|
|
281
|
+
if (type === "campaign.unparked") {
|
|
282
|
+
requireText(record.code, "entry.code");
|
|
283
|
+
// `runId` is optional, exactly as `outcome` treats it, but the unpark
|
|
284
|
+
// writer records an explicit null when the parked attention named no run.
|
|
285
|
+
if (record.runId !== null && record.runId !== undefined) requireText(record.runId, "entry.runId");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
requireText(record.text, "entry.text");
|
|
289
|
+
if (type === "decision") requireText(record.decisionId, "entry.decisionId");
|
|
290
|
+
if (type === "supersede") requireText(record.supersedes, "entry.supersedes");
|
|
291
|
+
if (type === "open-question" || type === "question.resolved") {
|
|
292
|
+
requireText(record.questionId, "entry.questionId");
|
|
293
|
+
}
|
|
294
|
+
if (type === "outcome" && record.runId !== undefined) requireText(record.runId, "entry.runId");
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* @param {JsonObject} entry
|
|
298
|
+
*/
|
|
299
|
+
function validateSessionEntry(entry) {
|
|
300
|
+
requireText(entry.tool, "session.tool");
|
|
301
|
+
requireText(entry.sessionId, "session.sessionId");
|
|
302
|
+
if (typeof entry.transcriptUnavailable !== "boolean") {
|
|
303
|
+
throw new TypeError("session.transcriptUnavailable must be a boolean");
|
|
304
|
+
}
|
|
305
|
+
if (entry.transcriptUnavailable) {
|
|
306
|
+
if (entry.transcript !== null) throw new TypeError("unavailable session must not include a transcript path");
|
|
307
|
+
if (entry.format !== null) throw new TypeError("unavailable session must not include a transcript format");
|
|
308
|
+
} else {
|
|
309
|
+
if (typeof entry.transcript !== "string" || !isAbsolute(entry.transcript)) {
|
|
310
|
+
throw new TypeError("session.transcript must be an absolute path when available");
|
|
311
|
+
}
|
|
312
|
+
if (typeof entry.format !== "string" || !entry.format.trim()) {
|
|
313
|
+
throw new TypeError("session.format must be a non-empty string when a transcript is available");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (entry.cursor !== null && entry.cursor !== undefined && (typeof entry.cursor !== "string" || !entry.cursor.trim())) {
|
|
317
|
+
throw new TypeError("session.cursor must be null, omitted, or a non-empty string");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* @param {JournalEntry} entry
|
|
322
|
+
* @returns {JournalEntry}
|
|
323
|
+
*/
|
|
324
|
+
function normalizeEntry(entry) {
|
|
325
|
+
/** @type {Record<string, unknown>} */
|
|
326
|
+
const normalized = {};
|
|
327
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
328
|
+
if (typeof value === "string") {
|
|
329
|
+
normalized[key] = key === "text" ? normalizeText(value, "entry.text") : collapseLines(value);
|
|
330
|
+
} else {
|
|
331
|
+
normalized[key] = value;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return /** @type {JournalEntry} */ (normalized);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* @param {unknown} value
|
|
338
|
+
* @param {string} label
|
|
339
|
+
* @param {number} maxBytes
|
|
340
|
+
* @returns {string}
|
|
341
|
+
*/
|
|
342
|
+
export function normalizeText(value, label, maxBytes = JOURNAL_TEXT_BYTES) {
|
|
343
|
+
requireText(value, label);
|
|
344
|
+
const collapsed = collapseLines(value);
|
|
345
|
+
if (!collapsed) throw new TypeError(`${label} must not be blank`);
|
|
346
|
+
return boundedText(collapsed, maxBytes);
|
|
347
|
+
}
|