flowviant 0.125.7 → 0.125.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -1
- package/bin/cli.mjs +7 -1
- package/bin/lib/activityFeed.mjs +69 -0
- package/bin/lib/agentMergeReports.mjs +46 -0
- package/bin/lib/agentMergeResolution.mjs +44 -0
- package/bin/lib/agentPlan.mjs +6 -2
- package/bin/lib/claudePosture.mjs +21 -0
- package/bin/lib/claudeStream.mjs +25 -4
- package/bin/lib/codexResearch.mjs +33 -5
- package/bin/lib/commandSpecs.mjs +8 -5
- package/bin/lib/daemonJournal.mjs +102 -0
- package/bin/lib/daemonLogging.mjs +22 -18
- package/bin/lib/fleet.mjs +16 -6
- package/bin/lib/fleetReports.mjs +12 -5
- package/bin/lib/hooks/orchestrator.mjs +3 -1
- package/bin/lib/logReplay.mjs +133 -0
- package/bin/lib/nativeAgentTools.mjs +20 -3
- package/bin/lib/observedLines.mjs +63 -0
- package/bin/lib/orchestrator.mjs +40 -7
- package/bin/lib/orchestratorNative.mjs +133 -3
- package/bin/lib/orchestratorPrompt.mjs +50 -4
- package/bin/lib/postureTools.mjs +14 -6
- package/bin/lib/printable.mjs +1 -1
- package/bin/lib/repositoryMerge.mjs +1 -1
- package/bin/lib/researchHandoff.mjs +144 -0
- package/bin/lib/researchHandoffText.mjs +3 -0
- package/bin/lib/researchKnowledge.mjs +60 -0
- package/bin/lib/researchRestoration.mjs +38 -0
- package/bin/lib/runTurn.mjs +76 -43
- package/bin/lib/runtimeClaude.mjs +9 -4
- package/bin/lib/runtimeCodex.mjs +51 -22
- package/bin/lib/runtimeEvents.mjs +17 -7
- package/bin/lib/turnDiagnostics.mjs +2 -1
- package/bin/lib/turnOutput.mjs +63 -0
- package/bin/lib/turnRecord.mjs +11 -0
- package/bin/lib/turnSubagents.mjs +13 -3
- package/bin/lib/ui.mjs +2 -2
- package/bin/lib/uplinkScrub.mjs +49 -0
- package/bin/lib/views.mjs +49 -58
- package/bin/lib/wikiRunner.mjs +2 -2
- package/bin/lib/work.mjs +2 -2
- package/bin/lib/workActivity.mjs +65 -0
- package/bin/lib/workAgentBriefRunner.mjs +2 -2
- package/bin/lib/workAgentMerges.mjs +57 -52
- package/bin/lib/workAgentPlans.mjs +76 -27
- package/bin/lib/workAgentPrecheck.mjs +1 -1
- package/bin/lib/workAgentTurnExecution.mjs +42 -8
- package/bin/lib/workAgentTurnReports.mjs +17 -3
- package/bin/lib/workAgentTurnShape.mjs +6 -4
- package/bin/lib/workAgentTurns.mjs +8 -6
- package/bin/lib/workIntake.mjs +8 -2
- package/bin/lib/workMergeBatch.mjs +7 -1
- package/bin/lib/workPlanRecovery.mjs +50 -0
- package/bin/lib/workSessionTurns.mjs +5 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ The binary and global npm installs update when idle. The binary fetches the rele
|
|
|
67
67
|
| `flowviant login` | Connect this repo to a project (approve the link it prints) |
|
|
68
68
|
| `flowviant stop` | Stop every flowviant daemon on this computer |
|
|
69
69
|
| `flowviant status` | Each project connected here: repo, running/serving, live agents |
|
|
70
|
-
| `flowviant logs [-f]` |
|
|
70
|
+
| `flowviant logs [-f] [-n <lines>] [--turn <id>] [--project <name|id>]` | Replay or follow retained local diagnostics; optionally select one turn |
|
|
71
71
|
| `flowviant open` | Open this project's board in the browser |
|
|
72
72
|
| `flowviant projects` | Every stored login and the repo it serves |
|
|
73
73
|
| `flowviant machines` | Every computer serving your projects; a menu on a terminal |
|
|
@@ -76,6 +76,85 @@ The binary and global npm installs update when idle. The binary fetches the rele
|
|
|
76
76
|
| `flowviant clean` | Delete kept agent worktrees |
|
|
77
77
|
| `flowviant uninstall` | Remove every copy (`--purge` for logins too) |
|
|
78
78
|
|
|
79
|
+
## Native activity feed
|
|
80
|
+
|
|
81
|
+
`flowviant` (or `flowviant start`) defaults to an append-only chronological
|
|
82
|
+
feed. Each work record carries its observation time, readable work name and
|
|
83
|
+
stable `@` turn label; observed native children keep their parent turn and a
|
|
84
|
+
child label. Long names and routine labels are shortened to fit ordinary
|
|
85
|
+
terminals; errors and questions keep their actual words and line breaks.
|
|
86
|
+
|
|
87
|
+
Starts, failures, interruptions, plan proposals and validated agent deliveries
|
|
88
|
+
appear immediately. A delivery says **Delivered for review**; only the merge
|
|
89
|
+
lane's measured landing says **Merged on the base branch**. Questions retain
|
|
90
|
+
their text and the existing app destination. Answer them in Flowviant.
|
|
91
|
+
Connection failures and recovery, serving changes and stopping remain visible.
|
|
92
|
+
|
|
93
|
+
Routine narration and tool activity are summarized as **Latest activity**
|
|
94
|
+
every ten seconds while fresh activity arrives, and flushed before immediate
|
|
95
|
+
observations or at close. A quiet interval prints nothing. A latest observation
|
|
96
|
+
is not proof that a command is still running. At most 256 pending turn/child
|
|
97
|
+
labels are held, each limited to 120 characters; reaching that bound flushes
|
|
98
|
+
them in observation order. This is a feed, not a live dashboard; `flowviant
|
|
99
|
+
status` remains the on-demand snapshot.
|
|
100
|
+
|
|
101
|
+
Use `flowviant --detail` or `flowviant start --detail` to print each runtime
|
|
102
|
+
activity as it arrives. This selects the existing detailed activity stream;
|
|
103
|
+
fuller supplied narration and tool data are available through logs below.
|
|
104
|
+
The choice survives inline login, PATH adoption and self-update relaunches.
|
|
105
|
+
It is inherited for that launch, not saved as a project preference. A service
|
|
106
|
+
can set `FLOWVIANT_OUTPUT=detail`; `--detail` also works on `flowviant login`.
|
|
107
|
+
Both presentations capture the same diagnostic detail before rendering.
|
|
108
|
+
Legacy idle snapshots stay in retained diagnostics in compact mode.
|
|
109
|
+
Environment-token launches attach the project journal when the roster first
|
|
110
|
+
identifies the project, before starting its CLI work. If
|
|
111
|
+
retention is unavailable or a write fails, the live record stays detailed.
|
|
112
|
+
|
|
113
|
+
## Local diagnostic detail
|
|
114
|
+
|
|
115
|
+
`flowviant logs` shows the last 80 retained lines for this repo's project,
|
|
116
|
+
including daemon diagnostics and native CLI detail. Use `-n 300` for more lines,
|
|
117
|
+
`-f` or `--follow` for new lines across rotation and daemon restarts, and
|
|
118
|
+
`--project <name|id>` for another connected project. To isolate concurrent work,
|
|
119
|
+
copy the `@` turn label from the compact feed (or the full `turn` id from detail):
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
flowviant logs --turn @<label> -n 300
|
|
123
|
+
flowviant logs --turn <turn-id> --follow --project <name-or-id>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The filter accepts an exact full turn id or the deterministic twelve-hex `@`
|
|
127
|
+
SHA-256 label of that id. It includes observed native children and excludes
|
|
128
|
+
unattributed daemon diagnostics. With no filter those diagnostics remain
|
|
129
|
+
visible. Replay includes the current log and its one predecessor, oldest first.
|
|
130
|
+
Each project retains **two files of at most 2 MiB each**, under
|
|
131
|
+
`~/.flowviant/daemon-<project-hash>.log` and `.log.1`, with mode `0600`.
|
|
132
|
+
Retention is by bytes, not a guaranteed duration; older detail expires as work
|
|
133
|
+
continues. New log directories use mode `0700`.
|
|
134
|
+
|
|
135
|
+
Native entries retain observed timestamps, readable work names, full work/turn
|
|
136
|
+
ids and available native-child ids, with supplied narration, answers, tool
|
|
137
|
+
calls/results and CLI stderr/errors. This is **local diagnostic detail, not a
|
|
138
|
+
complete vendor transcript**: it contains no unavailable reasoning or raw
|
|
139
|
+
vendor event archive, and only tool information the adapters expose. Each
|
|
140
|
+
encoded native record is capped at **64 KiB**, after redaction; a clipped entry
|
|
141
|
+
carries `[truncated: local detail record exceeded 64 KiB]`. Oversized legacy
|
|
142
|
+
console entries carry `[truncated]`. Tail limits, detected rotation/write gaps,
|
|
143
|
+
unreadable records and missing selected-turn detail are explicit. A missing
|
|
144
|
+
turn may have expired or predate this capture; it does not mean the turn did
|
|
145
|
+
not run.
|
|
146
|
+
|
|
147
|
+
Capture is independent of app trace uploads and foreground presentation. Local
|
|
148
|
+
storage and replay use the daemon's existing learned-secret and credential-shape
|
|
149
|
+
scrubber, with its documented exemptions; supplied per-turn MCP/login values
|
|
150
|
+
are masked too. Known multiline secret fragments and streamed PEM bodies are
|
|
151
|
+
masked before storage. Terminal controls are removed at storage and replay.
|
|
152
|
+
Unknown secrets outside those sources/shapes remain outside that guarantee.
|
|
153
|
+
If storage fails, work continues, the daemon reports that detail could not be
|
|
154
|
+
retained, and a successful later write marks the gap. Foreground presentation
|
|
155
|
+
never changes retained detail; desktop `--json-events` still reserves stdout for
|
|
156
|
+
machine events and writes human output to stderr.
|
|
157
|
+
|
|
79
158
|
## Uninstall
|
|
80
159
|
|
|
81
160
|
`flowviant uninstall` stops local daemons and removes installed copies. It keeps logins and local state in `~/.flowviant`, so reinstalling resumes them. Use `flowviant uninstall --purge` to disconnect this box from stored projects and delete that local data too. Add `--yes` to skip confirmation.
|
package/bin/cli.mjs
CHANGED
|
@@ -115,6 +115,11 @@ if (process.argv[2] === '__hook') {
|
|
|
115
115
|
// is that the login ended; wait for stdout to drain before exiting.
|
|
116
116
|
const flushStdout = () => new Promise((resolve) => process.stdout.write('', resolve));
|
|
117
117
|
|
|
118
|
+
// Preserve an explicit foreground choice across login, PATH and update
|
|
119
|
+
// relaunches, including paths that build fresh argv. No setting is saved.
|
|
120
|
+
const { outputMode, configureActivityOutput } = await import('./lib/activityFeed.mjs');
|
|
121
|
+
process.env.FLOWVIANT_OUTPUT = outputMode(process.argv.slice(2), process.env);
|
|
122
|
+
|
|
118
123
|
// `flowviant start` is the spelled-out form of a bare `flowviant`; everything
|
|
119
124
|
// below reads the start path from argv, so the word is simply dropped.
|
|
120
125
|
if (process.argv[2] === 'start' && !process.argv.slice(3).some((arg) => arg === '--help' || arg === '-h')) process.argv.splice(2, 1);
|
|
@@ -229,7 +234,7 @@ async function statusCommand() {
|
|
|
229
234
|
async function logsCommand() {
|
|
230
235
|
const { runLogs } = await import('./lib/views.mjs');
|
|
231
236
|
await runLogs(process.argv);
|
|
232
|
-
process.exit(0);
|
|
237
|
+
process.exit(process.exitCode || 0);
|
|
233
238
|
}
|
|
234
239
|
|
|
235
240
|
// `flowviant open` — this repo's board, in the browser.
|
|
@@ -484,4 +489,5 @@ const { installDaemonLogging } = await import('./lib/daemonLogging.mjs');
|
|
|
484
489
|
const { daemonLogPath } = await import('./lib/daemonState.mjs');
|
|
485
490
|
const logProjectId = CREDENTIAL?.entry?.projectId;
|
|
486
491
|
installDaemonLogging(logProjectId ? daemonLogPath(logProjectId) : null, { jsonEvents: process.argv.includes('--json-events') });
|
|
492
|
+
configureActivityOutput(process.env.FLOWVIANT_OUTPUT);
|
|
487
493
|
await runFleetDaemon({ afterLock });
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Local foreground presentation. Retention happens before this sink. Unknown
|
|
2
|
+
* or failed retention always falls back to the complete detailed live record.
|
|
3
|
+
* A timer consumes fresh observations; it never infers liveness from silence.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { c, stamp } from './ui.mjs';
|
|
7
|
+
import { printableLine, printableText } from './printable.mjs';
|
|
8
|
+
|
|
9
|
+
export const turnToken = id => '@' + createHash('sha256').update(id).digest('hex').slice(0, 12);
|
|
10
|
+
export const matchesTurn = (id, selection) => selection === id || selection === turnToken(id);
|
|
11
|
+
export const outputMode = (argv, env) => argv.includes('--detail') || env.FLOWVIANT_OUTPUT === 'detail' ? 'detail' : 'compact';
|
|
12
|
+
const routine = new Set(['think', 'say', 'bash', 'glob', 'list', 'plan', 'read', 'search', 'tool', 'write']);
|
|
13
|
+
|
|
14
|
+
export function formatActivityRecord(record) {
|
|
15
|
+
const name = printableLine(printableText(record.name), 24);
|
|
16
|
+
const child = record.agentId ? ` child=${turnToken(record.agentId)}` : '';
|
|
17
|
+
const prefix = `${stamp(record.observedAt)} ${c.cyan(`[${printableLine(record.kind, 16)} ${name} ${turnToken(record.turnId)}${child}]`)} `;
|
|
18
|
+
const body = printableText(record.text).replace(/\n$/, '');
|
|
19
|
+
return body.split('\n').map(line => prefix + line).join('\n') + '\n';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createActivityFeed({ intervalMs = 10_000, maxPending = 256,
|
|
23
|
+
schedule = setTimeout, cancel = clearTimeout } = {}) {
|
|
24
|
+
const pending = new Map();
|
|
25
|
+
let timer = null;
|
|
26
|
+
const flush = () => {
|
|
27
|
+
if (timer !== null) { cancel(timer); timer = null; }
|
|
28
|
+
const rows = [...pending.values()];
|
|
29
|
+
pending.clear();
|
|
30
|
+
for (const { record, write } of rows) {
|
|
31
|
+
// Only the label is coalesced. Full text was already retained, including
|
|
32
|
+
// tools/results. Observation time belongs to the latest supplied event.
|
|
33
|
+
const latest = { ...record, text: `Latest activity: ${printableLine(record.text, 120)}` };
|
|
34
|
+
write(formatActivityRecord(latest), latest.source, latest, true);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
present(record, detail, retained, write) {
|
|
39
|
+
if (retained === true && record.source === 'activity' && routine.has(record.activityKind)) {
|
|
40
|
+
const key = JSON.stringify([record.turnId, record.agentId]);
|
|
41
|
+
pending.delete(key); // Map order is the order of the latest observations.
|
|
42
|
+
pending.set(key, { record: { ...record, text: printableLine(record.text, 120) }, write });
|
|
43
|
+
if (pending.size >= maxPending) flush();
|
|
44
|
+
else if (timer === null) { timer = schedule(flush, intervalMs); timer.unref?.(); }
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// A question/error/outcome is immediate, after older pending activity.
|
|
48
|
+
flush();
|
|
49
|
+
write(retained === true ? formatActivityRecord(record) : detail, record.source, record, retained);
|
|
50
|
+
},
|
|
51
|
+
flush,
|
|
52
|
+
close(turnId) {
|
|
53
|
+
if ([...pending.values()].some(row => row.record.turnId === turnId)) flush();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let feed = null;
|
|
59
|
+
export const compactOutput = () => feed !== null;
|
|
60
|
+
export function configureActivityOutput(mode) {
|
|
61
|
+
feed?.flush();
|
|
62
|
+
feed = mode === 'compact' ? createActivityFeed() : null;
|
|
63
|
+
}
|
|
64
|
+
export const flushActivityOutput = () => feed?.flush();
|
|
65
|
+
export const closeActivityTurn = turnId => feed?.close(turnId);
|
|
66
|
+
export function presentTurnRecord(record, detail, retained, write) {
|
|
67
|
+
if (feed) feed.present(record, detail, retained, write);
|
|
68
|
+
else write(detail, record.source, record, retained);
|
|
69
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Persist an outcome before sending it. Uncertain acknowledgement retries the
|
|
2
|
+
* receipt, never the Git operation; the original lease instance survives restart. */
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createReportJournal } from './reportJournal.mjs';
|
|
5
|
+
import { fleetRequest } from './fleetPost.mjs';
|
|
6
|
+
import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
|
|
7
|
+
import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
|
|
8
|
+
|
|
9
|
+
export function createMergeReports({ baseDir, delivered = async () => {} }) {
|
|
10
|
+
const journal = createReportJournal(join(baseDir, 'agent-merge-reports'));
|
|
11
|
+
const pending = new Map(journal.load().work.filter(([id, held]) =>
|
|
12
|
+
held?.body?.agentId === id && typeof held.body.ok === 'boolean' && typeof held.body.instance === 'string'));
|
|
13
|
+
const sending = new Set();
|
|
14
|
+
const retryAt = new Map();
|
|
15
|
+
const save = () => journal.save(pending, new Map());
|
|
16
|
+
const send = async (id) => {
|
|
17
|
+
if (sending.has(id) || (retryAt.get(id) ?? 0) > Date.now()) return false;
|
|
18
|
+
const held = pending.get(id);
|
|
19
|
+
if (!held) return true;
|
|
20
|
+
sending.add(id);
|
|
21
|
+
try {
|
|
22
|
+
const r = await fleetRequest(fleetEndpoint('agent-merge-done', FLEET_URL), { body: held.body });
|
|
23
|
+
// A parsed receipt includes already-settled/lost-holder results. Auth,
|
|
24
|
+
// HTML, malformed JSON and deploy skew retain the exact body.
|
|
25
|
+
if (r.ok && r.json?.success === true && typeof r.json?.data?.settled === 'boolean') {
|
|
26
|
+
pending.delete(id);
|
|
27
|
+
retryAt.delete(id);
|
|
28
|
+
save();
|
|
29
|
+
await delivered(held, r.json.data.settled);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
retryAt.set(id, Date.now() + (r.retryAfterMs ?? (isConsideredRefusal(r.status) ? 60_000 : 1000)));
|
|
33
|
+
return false;
|
|
34
|
+
} finally { sending.delete(id); }
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
pending,
|
|
38
|
+
async report(body, meta = {}) {
|
|
39
|
+
const id = String(body.agentId);
|
|
40
|
+
pending.set(id, { body: { ...body, instance: DAEMON_INSTANCE }, ...meta });
|
|
41
|
+
save();
|
|
42
|
+
return send(id);
|
|
43
|
+
},
|
|
44
|
+
async flush() { for (const id of [...pending.keys()]) await send(id); },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Evidence belongs to the failed attempt, so an answer after committing can
|
|
2
|
+
* verify the same repair without inventing another commit. */
|
|
3
|
+
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { git } from './git.mjs';
|
|
6
|
+
|
|
7
|
+
const marker = (wt) => resolve(wt, git(['rev-parse', '--git-path', 'flowviant-merge-resolution'], wt));
|
|
8
|
+
const sha = (value) => typeof value === 'string' && /^[a-f0-9]{40,64}$/.test(value);
|
|
9
|
+
|
|
10
|
+
export function gitReportedConflict(error, wt) {
|
|
11
|
+
if (/^CONFLICT \(/m.test(`${error?.stdout ?? ''}\n${error?.stderr ?? ''}`)) return true;
|
|
12
|
+
try {
|
|
13
|
+
return Boolean(git(['diff', '--name-only', '--diff-filter=U'], wt) || git(['rev-parse', '--verify', 'MERGE_HEAD'], wt));
|
|
14
|
+
} catch { return false; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function clearMergeFailure(wt) {
|
|
18
|
+
try { rmSync(marker(wt), { force: true }); } catch { /* no marker */ }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function recordMergeFailure(wt, base, conflict) {
|
|
22
|
+
try {
|
|
23
|
+
writeFileSync(marker(wt), JSON.stringify({ before: git(['rev-parse', 'HEAD'], wt), base: git(['rev-parse', base], wt), conflict }));
|
|
24
|
+
} catch { clearMergeFailure(wt); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function measuredMergeResolution(wt, before, base) {
|
|
28
|
+
let start;
|
|
29
|
+
try { start = JSON.parse(readFileSync(marker(wt), 'utf8')); }
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error?.code !== 'ENOENT') return false;
|
|
32
|
+
start = { before, base: (() => { try { return git(['rev-parse', base], wt); } catch { return null; } })(), conflict: true };
|
|
33
|
+
}
|
|
34
|
+
if (!sha(start?.before) || !sha(start?.base) || typeof start.conflict !== 'boolean') return false;
|
|
35
|
+
try {
|
|
36
|
+
const tip = git(['rev-parse', 'HEAD'], wt);
|
|
37
|
+
if (start.conflict && tip === start.before) return false;
|
|
38
|
+
if (git(['status', '--porcelain', '--untracked-files=no'], wt)) return false;
|
|
39
|
+
if (gitReportedConflict(null, wt)) return false;
|
|
40
|
+
git(['merge-base', '--is-ancestor', start.before, tip], wt);
|
|
41
|
+
git(['merge-base', '--is-ancestor', start.base, tip], wt);
|
|
42
|
+
return true;
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
}
|
package/bin/lib/agentPlan.mjs
CHANGED
|
@@ -48,8 +48,12 @@ function extract(raw) {
|
|
|
48
48
|
|
|
49
49
|
export function parseProposal(text, scrub = envScrub) {
|
|
50
50
|
const raw = String(text ?? '');
|
|
51
|
-
const
|
|
52
|
-
if (!
|
|
51
|
+
const found = extract(raw);
|
|
52
|
+
if (!found) return null;
|
|
53
|
+
// A model says "none" with null as often as with []: both are an absent key,
|
|
54
|
+
// never a malformed list that throws the whole plan away.
|
|
55
|
+
const parsed = { ...found };
|
|
56
|
+
for (const key of ['dependencies', 'collisions', 'evidence', 'repos']) if (parsed[key] === null) delete parsed[key];
|
|
53
57
|
|
|
54
58
|
const agents = [];
|
|
55
59
|
for (const [i, g] of parsed.agents.slice(0, MAX_AGENTS).entries()) {
|
|
@@ -478,6 +478,27 @@ export function researchPerm(knowledgeDir, projectMcp = []) {
|
|
|
478
478
|
/** The list with no library — what a project that keeps none runs under. */
|
|
479
479
|
export const RESEARCH_PERM = researchPerm(null);
|
|
480
480
|
|
|
481
|
+
/**
|
|
482
|
+
* AN ORCHESTRATED RESEARCH CARD'S LIST (2026-10-19): the `Agent` its fence
|
|
483
|
+
* denies is allowed instead, so the gate's children can start. MEASURED on
|
|
484
|
+
* Claude Code 2.1.303 (offline): a research child inherits the parent's rules
|
|
485
|
+
* — a Write to `src/` and a `touch` were refused, `/etc/hostname` and `.env`
|
|
486
|
+
* unreadable, its PreToolUse hooks carried `agent_id` — but its Write under
|
|
487
|
+
* `.flowviant/artifacts/` landed, so the gate refuses a child's file writes
|
|
488
|
+
* (orchestrator.mjs, nativeAgentTools.mjs `write`). Every other entry stays.
|
|
489
|
+
*/
|
|
490
|
+
export function withNativeChildren(perm) {
|
|
491
|
+
const out = [];
|
|
492
|
+
let denying = false;
|
|
493
|
+
for (const arg of perm) {
|
|
494
|
+
if (arg.startsWith('--')) denying = arg === '--disallowedTools';
|
|
495
|
+
if (denying && arg === 'Agent') continue;
|
|
496
|
+
out.push(arg);
|
|
497
|
+
if (arg === '--allowedTools') out.push('Agent');
|
|
498
|
+
}
|
|
499
|
+
return out;
|
|
500
|
+
}
|
|
501
|
+
|
|
481
502
|
/**
|
|
482
503
|
* CLAUDE'S PERMISSION LIST FOR A TURN PROFILE, by the profile's name — one
|
|
483
504
|
* entry per profile Claude declares, plus plan-mode (runtimeClaude.mjs
|
package/bin/lib/claudeStream.mjs
CHANGED
|
@@ -100,7 +100,7 @@ export function taskPlanChanged(tasks, result) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export function handleStreamLine(line, options) {
|
|
103
|
-
const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents, tasks } = options;
|
|
103
|
+
const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents, tasks, onDetail } = options;
|
|
104
104
|
let ev;
|
|
105
105
|
try {
|
|
106
106
|
ev = JSON.parse(line);
|
|
@@ -118,9 +118,11 @@ export function handleStreamLine(line, options) {
|
|
|
118
118
|
// A sidechain with no identity must never be attributed to the parent.
|
|
119
119
|
if (ev.isSidechain && !agentId) return;
|
|
120
120
|
subagents?.observe(ev);
|
|
121
|
+
if (ev.type === 'result' && !ev.is_error && typeof ev.result === 'string')
|
|
122
|
+
onDetail?.(ev.result, { source: 'answer', ...(agentId ? { agentId } : {}) });
|
|
121
123
|
const push = (a) => {
|
|
122
124
|
if (!a || !a.label) return;
|
|
123
|
-
emit(a.label + '\n');
|
|
125
|
+
emit(a.kind === 'error' ? a.full ?? a.label : a.label + '\n', { ...a, ...(agentId ? { agentId } : {}) });
|
|
124
126
|
onActivity?.({ ...a, ...(agentId ? { agentId } : {}) });
|
|
125
127
|
};
|
|
126
128
|
if (ev.type === 'stream_event') {
|
|
@@ -198,6 +200,7 @@ export function handleStreamLine(line, options) {
|
|
|
198
200
|
id: b.tool_use_id, result: true, isError: b.is_error === true,
|
|
199
201
|
...(agentId ? { agentId } : {}),
|
|
200
202
|
output: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.slice(0, 8).filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
|
|
203
|
+
detail: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
|
|
201
204
|
exitCode: ev.tool_use_result?.exit_code ?? ev.tool_use_result?.exitCode,
|
|
202
205
|
});
|
|
203
206
|
}
|
|
@@ -237,6 +240,13 @@ export function handleStreamLine(line, options) {
|
|
|
237
240
|
tools: Array.isArray(ev.tools) ? ev.tools : undefined,
|
|
238
241
|
});
|
|
239
242
|
}
|
|
243
|
+
} else if (ev.type === 'result' && agentId && ev.is_error) {
|
|
244
|
+
// A child's failure belongs in its record, never in the parent's answer
|
|
245
|
+
// or token count. The stream's observed spawn-call id remains its owner.
|
|
246
|
+
const listed = Array.isArray(ev.errors) ? ev.errors.filter(e => typeof e === 'string' && e.trim()).join('\n') : '';
|
|
247
|
+
const msg = listed || ev.result || ev.error?.message || ev.error || ev.subtype;
|
|
248
|
+
const full = typeof msg === 'string' ? msg : JSON.stringify(msg);
|
|
249
|
+
if (full) push({ kind: 'error', label: oneLine(full), full });
|
|
240
250
|
} else if (ev.type === 'result' && !agentId) {
|
|
241
251
|
/**
|
|
242
252
|
* WHAT THE TURN SPENT, BEFORE ANYTHING ELSE IN THIS BRANCH.
|
|
@@ -251,7 +261,10 @@ export function handleStreamLine(line, options) {
|
|
|
251
261
|
const usage = usageFromResult(ev);
|
|
252
262
|
if (usage) onUsage?.(usage);
|
|
253
263
|
// The final assistant text (carries WIKI_DONE / REGROUND_DONE).
|
|
254
|
-
if (typeof ev.result === 'string')
|
|
264
|
+
if (typeof ev.result === 'string') {
|
|
265
|
+
appendText(ev.result + '\n');
|
|
266
|
+
if (ev.is_error) push({ kind: 'error', label: oneLine(ev.result), full: ev.result });
|
|
267
|
+
}
|
|
255
268
|
else if (ev.is_error || ev.subtype) {
|
|
256
269
|
// A result that carries no text is a FAILED turn (a limit, a refused
|
|
257
270
|
// permission, an aborted run). Under `answerFromResult` this is the only
|
|
@@ -268,7 +281,15 @@ export function handleStreamLine(line, options) {
|
|
|
268
281
|
? ev.errors.filter((e) => typeof e === 'string' && e.trim()).join('; ')
|
|
269
282
|
: '';
|
|
270
283
|
const msg = listed || ev.error?.message || ev.error || ev.subtype;
|
|
271
|
-
|
|
284
|
+
const text = typeof msg === 'string' ? msg : JSON.stringify(msg);
|
|
285
|
+
appendText(`${text}\n`);
|
|
286
|
+
if (ev.is_error || listed || ev.error || String(ev.subtype).startsWith('error'))
|
|
287
|
+
push({ kind: 'error', label: oneLine(text), full: listed ? ev.errors.filter(e => typeof e === 'string' && e.trim()).join('\n') : text });
|
|
288
|
+
}
|
|
289
|
+
} else if (ev.type === 'error') {
|
|
290
|
+
const text = ev.error?.message ?? ev.message;
|
|
291
|
+
if (typeof text === 'string' && text) {
|
|
292
|
+
push({ kind: 'error', label: oneLine(text), full: text });
|
|
272
293
|
}
|
|
273
294
|
} else if (ev.type === 'rate_limit_event') {
|
|
274
295
|
/**
|
|
@@ -10,6 +10,7 @@ import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
|
|
|
10
10
|
import { closeSync, constants, copyFileSync, existsSync, fstatSync, openSync, readFileSync, readSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
12
12
|
import { createRequire } from 'node:module';
|
|
13
|
+
import { prepareResearchLaunch } from './researchHandoff.mjs';
|
|
13
14
|
import { versionAtLeast } from './versionOrder.mjs';
|
|
14
15
|
|
|
15
16
|
const MAX_FILES = 20_000;
|
|
@@ -120,7 +121,7 @@ export function researchFence({ cwd, helper, generatedImages }) {
|
|
|
120
121
|
* nothing. Artifact publication stays in the original worktree for the
|
|
121
122
|
* existing uploader.
|
|
122
123
|
*/
|
|
123
|
-
export function publishResearch(source, destination) {
|
|
124
|
+
export function publishResearch(source, destination, inputs = new Map()) {
|
|
124
125
|
if (realDirState(destination) === 'none') mkdirSync(destination, { recursive: true });
|
|
125
126
|
if (realDirState(destination) !== 'dir') throw new Error('The research artifact destination is not a real directory.');
|
|
126
127
|
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
@@ -128,7 +129,7 @@ export function publishResearch(source, destination) {
|
|
|
128
129
|
const to = join(destination, entry.name);
|
|
129
130
|
const stat = lstatSync(from);
|
|
130
131
|
if (stat.isSymbolicLink()) throw new Error('An isolated research output is a symlink; nothing was accepted through it.');
|
|
131
|
-
if (stat.isDirectory()) publishResearch(from, to);
|
|
132
|
+
if (stat.isDirectory()) publishResearch(from, to, inputs);
|
|
132
133
|
else if (stat.isFile() && /\.md$/i.test(entry.name)) {
|
|
133
134
|
if (stat.size > 2 * 1024 * 1024) throw new Error('The research Markdown output exceeds 2 MB.');
|
|
134
135
|
let there = null;
|
|
@@ -139,6 +140,7 @@ export function publishResearch(source, destination) {
|
|
|
139
140
|
}
|
|
140
141
|
if (there && !there.isFile()) throw new Error('The research output would overwrite a non-file.');
|
|
141
142
|
const bytes = readFileSync(from);
|
|
143
|
+
if (inputs.get(from)?.equals(bytes)) continue;
|
|
142
144
|
if (there && there.size === bytes.length && readFileSync(to).equals(bytes)) continue;
|
|
143
145
|
if (there) unlinkSync(to);
|
|
144
146
|
writeFileSync(to, bytes, { flag: 'wx' });
|
|
@@ -146,8 +148,27 @@ export function publishResearch(source, destination) {
|
|
|
146
148
|
}
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
// Retained context is person/source scoped. Do not make another discussion's
|
|
152
|
+
// reports readable merely because they happen to stand in this worktree.
|
|
153
|
+
function retainResearchInputs(dir, names, relative = '') {
|
|
154
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
155
|
+
const name = relative + entry.name, path = join(dir, entry.name);
|
|
156
|
+
if (entry.isDirectory()) retainResearchInputs(path, names, name + '/');
|
|
157
|
+
else if (!names.has(name)) rmSync(path, { force: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function rememberResearchInputs(dir, inputs = new Map()) {
|
|
161
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
162
|
+
const path = join(dir, entry.name);
|
|
163
|
+
if (entry.isDirectory()) rememberResearchInputs(path, inputs);
|
|
164
|
+
else if (entry.isFile() && /\.md$/i.test(entry.name)) inputs.set(path, readFileSync(path));
|
|
165
|
+
}
|
|
166
|
+
return inputs;
|
|
167
|
+
}
|
|
168
|
+
|
|
149
169
|
export async function runIsolatedResearch(opts, run, {
|
|
150
170
|
resolveHelper = codexResearchHelper,
|
|
171
|
+
prepareLaunch = prepareResearchLaunch,
|
|
151
172
|
readVersion = (helper) => execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10_000 }),
|
|
152
173
|
} = {}) {
|
|
153
174
|
if (process.platform !== 'linux') throw new Error('Isolated Codex research requires Linux or WSL.');
|
|
@@ -171,11 +192,16 @@ export async function runIsolatedResearch(opts, run, {
|
|
|
171
192
|
try {
|
|
172
193
|
copyResearchView(original, snapshot);
|
|
173
194
|
let knowledge = null;
|
|
174
|
-
if (opts.
|
|
195
|
+
if (Number.isInteger(opts.researchHandoff?.knowledgeRev)) {
|
|
196
|
+
// The launch loader will build this from the held current manifest.
|
|
197
|
+
// Never copy an unmeasured/background-sync mirror into its input view.
|
|
198
|
+
knowledge = join(snapshot, '.flowviant/knowledge');
|
|
199
|
+
} else if (opts.knowledgeDir) {
|
|
175
200
|
knowledge = join(snapshot, '.flowviant/knowledge');
|
|
176
201
|
copyResearchView(realpathSync(opts.knowledgeDir), knowledge);
|
|
177
202
|
}
|
|
178
203
|
for (const dir of ['artifacts', 'tmp']) mkdirSync(join(snapshot, '.flowviant', dir), { recursive: true, mode: 0o700 });
|
|
204
|
+
if (opts.researchHandoff) retainResearchInputs(join(snapshot, '.flowviant/artifacts'), new Set(opts.researchHandoff.artifacts.filter(a => !a.unavailable).map(a => a.name)));
|
|
179
205
|
const rewrite = (text) => typeof text === 'string'
|
|
180
206
|
? text.replaceAll(original, snapshot).replaceAll(opts.knowledgeDir || '\0', knowledge || '\0') : text;
|
|
181
207
|
const intro = 'This fresh research turn uses an isolated read-only copy. Prior reports are available under .flowviant/artifacts/; previous CLI conversations are not resumed because they may contain host secrets. Git metadata, .env files, credential files, symlinks and personal CLI settings are omitted. Write the Markdown report only under .flowviant/artifacts/. Web search is available; shell networking is disabled. Use exec_command with shell=/bin/bash and login=false; personal shells are outside the fence.\n\n';
|
|
@@ -187,13 +213,15 @@ export async function runIsolatedResearch(opts, run, {
|
|
|
187
213
|
throw new Error('The native generated image directory is not a real directory.');
|
|
188
214
|
generatedImages = join(imageHome, 'generated_images');
|
|
189
215
|
}
|
|
190
|
-
const
|
|
216
|
+
const prepared = await prepareLaunch({ ...opts, resume: false, resumeThreadId: null, resumeConversationId: null, adoptResumeId: null, cwd: snapshot, knowledgeDir: knowledge,
|
|
191
217
|
prompt: intro + rewrite(opts.prompt), system: rewrite(opts.system),
|
|
192
218
|
researchIsolation: { cwd: snapshot, helper, generatedImages } });
|
|
219
|
+
const inputs = rememberResearchInputs(join(snapshot, '.flowviant/artifacts'));
|
|
220
|
+
const result = await run(prepared);
|
|
193
221
|
// Made, or found, real at publication too: every component checked again.
|
|
194
222
|
if (!ensureRealSubdir(original, '.flowviant/artifacts'))
|
|
195
223
|
throw new Error('The research artifact destination is not a real directory.');
|
|
196
|
-
publishResearch(join(snapshot, '.flowviant/artifacts'), join(original, '.flowviant/artifacts'));
|
|
224
|
+
publishResearch(join(snapshot, '.flowviant/artifacts'), join(original, '.flowviant/artifacts'), inputs);
|
|
197
225
|
return typeof result === 'string' ? result.replaceAll(snapshot, original) : result;
|
|
198
226
|
} finally { rmSync(isolation, { recursive: true, force: true }); }
|
|
199
227
|
}
|
package/bin/lib/commandSpecs.mjs
CHANGED
|
@@ -30,10 +30,13 @@ export const COMMANDS = [
|
|
|
30
30
|
{
|
|
31
31
|
name: 'start',
|
|
32
32
|
usesCheckout: true,
|
|
33
|
-
usage: 'flowviant [start] [--project <name|id>]',
|
|
33
|
+
usage: 'flowviant [start] [--detail] [--project <name|id>]',
|
|
34
34
|
summary: "Serve this repo's project: run agents and Terminal tabs here. Plain `flowviant` does the same.",
|
|
35
35
|
details: [
|
|
36
36
|
'Run it inside the repository you connected. The first run in a new repo asks you to approve this computer.',
|
|
37
|
+
'Default: compact chronological activity, with fresh routine summaries every 10 seconds and at event boundaries.',
|
|
38
|
+
'--detail print each runtime activity in the foreground; preserved through login and update relaunches',
|
|
39
|
+
'flowviant logs -f --turn @<label> follow retained detail for the short turn label in the feed (full ids also work)',
|
|
37
40
|
'--project <name|id> serve that stored project instead of the one this repo is bound to',
|
|
38
41
|
],
|
|
39
42
|
group: 'run',
|
|
@@ -41,9 +44,9 @@ export const COMMANDS = [
|
|
|
41
44
|
{
|
|
42
45
|
name: 'login',
|
|
43
46
|
usesCheckout: true,
|
|
44
|
-
usage: 'flowviant login [--no-start]',
|
|
47
|
+
usage: 'flowviant login [--no-start] [--detail]',
|
|
45
48
|
summary: 'Connect this repo to a project: approve the code in your browser, then start serving.',
|
|
46
|
-
details: ['--no-start save the connection without starting the daemon'],
|
|
49
|
+
details: ['--no-start save the connection without starting the daemon', '--detail start with the detailed foreground stream'],
|
|
47
50
|
group: 'run',
|
|
48
51
|
},
|
|
49
52
|
{
|
|
@@ -64,9 +67,9 @@ export const COMMANDS = [
|
|
|
64
67
|
{
|
|
65
68
|
name: 'logs',
|
|
66
69
|
usesCheckout: true,
|
|
67
|
-
usage: 'flowviant logs [-f] [--project <name|id>]',
|
|
70
|
+
usage: 'flowviant logs [-f] [-n <lines>] [--turn <id>] [--project <name|id>]',
|
|
68
71
|
summary: "The daemon's log for this repo's project.",
|
|
69
|
-
details: ['-f, --follow keep printing new lines
|
|
72
|
+
details: ['-f, --follow keep printing new retained lines across rotation and restart', '-n <lines> how many matching lines to show first (default 80)', '--turn <id> select an exact full id or the @short label in the feed, including native children', '--project <name|id> select a connected project', 'Retains the current 2 MiB log and one predecessor; older detail expires.', 'Native records keep supplied prose and tool data, at most 64 KiB each.', 'Truncation and detected gaps are marked. This is diagnostic detail, not a full transcript.'],
|
|
70
73
|
group: 'look',
|
|
71
74
|
},
|
|
72
75
|
{
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Bounded local diagnostic journal. Native records share the existing project
|
|
2
|
+
* log with legacy console lines. Each native record occupies one JSON line;
|
|
3
|
+
* replay renders its text, rather than presenting shortened labels as history.
|
|
4
|
+
*/
|
|
5
|
+
import { appendFileSync, closeSync, constants, fchmodSync, fstatSync, mkdirSync, openSync, renameSync } from 'node:fs';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
8
|
+
import { printableLine, printableText } from './printable.mjs';
|
|
9
|
+
import { scrub } from './uplinkScrub.mjs';
|
|
10
|
+
|
|
11
|
+
export const MAX_DAEMON_LOG_BYTES = 2 * 1024 * 1024;
|
|
12
|
+
export const MAX_DETAIL_RECORD_BYTES = 64 * 1024;
|
|
13
|
+
export const DETAIL_PREFIX = '@flowviant-detail ';
|
|
14
|
+
export const DETAIL_TRUNCATED = '\n[truncated: local detail record exceeded 64 KiB]';
|
|
15
|
+
export const DETAIL_GAP = '[detail gap: local log writes failed; some detail was not retained]';
|
|
16
|
+
|
|
17
|
+
export const utf8Prefix = (text, bytes) => new StringDecoder('utf8').write((Buffer.isBuffer(text) ? text : Buffer.from(text)).subarray(0, Math.max(0, bytes)));
|
|
18
|
+
const safe = text => printableText(scrub(printableText(scrub(String(text ?? '')))));
|
|
19
|
+
|
|
20
|
+
export function encodeDetailRecord(record) {
|
|
21
|
+
const row = { v: 1, observedAt: record.observedAt, kind: printableLine(safe(record.kind), 120), name: printableLine(safe(record.name), 120),
|
|
22
|
+
workId: safe(record.workId), turnId: safe(record.turnId), source: safe(record.source),
|
|
23
|
+
...(record.agentId ? { agentId: safe(record.agentId) } : {}), text: safe(record.text) };
|
|
24
|
+
const encode = () => DETAIL_PREFIX + JSON.stringify(row) + '\n';
|
|
25
|
+
const encoded = row.text.length <= MAX_DETAIL_RECORD_BYTES ? encode() : null;
|
|
26
|
+
if (encoded && Buffer.byteLength(encoded) <= MAX_DETAIL_RECORD_BYTES) return encoded;
|
|
27
|
+
const full = Buffer.from(row.text);
|
|
28
|
+
row.truncated = true;
|
|
29
|
+
row.text = DETAIL_TRUNCATED;
|
|
30
|
+
if (Buffer.byteLength(encode()) > MAX_DETAIL_RECORD_BYTES) throw new Error('Detail attribution exceeds the record limit');
|
|
31
|
+
let low = 0, high = Math.min(full.length, MAX_DETAIL_RECORD_BYTES);
|
|
32
|
+
while (low < high) {
|
|
33
|
+
const mid = Math.ceil((low + high) / 2);
|
|
34
|
+
row.text = utf8Prefix(full, mid) + DETAIL_TRUNCATED;
|
|
35
|
+
if (Buffer.byteLength(encode()) <= MAX_DETAIL_RECORD_BYTES) low = mid;
|
|
36
|
+
else high = mid - 1;
|
|
37
|
+
}
|
|
38
|
+
row.text = utf8Prefix(full, low) + DETAIL_TRUNCATED;
|
|
39
|
+
return encode();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function decodeDetailRecord(line) {
|
|
43
|
+
if (!line.startsWith(DETAIL_PREFIX)) return null;
|
|
44
|
+
if (Buffer.byteLength(line) > MAX_DETAIL_RECORD_BYTES) return false;
|
|
45
|
+
try {
|
|
46
|
+
const row = JSON.parse(line.slice(DETAIL_PREFIX.length));
|
|
47
|
+
if (row?.v !== 1 || !Number.isFinite(row.observedAt) ||
|
|
48
|
+
!['kind', 'name', 'workId', 'turnId', 'source', 'text'].every(k => typeof row[k] === 'string') ||
|
|
49
|
+
row.agentId !== undefined && typeof row.agentId !== 'string') return false;
|
|
50
|
+
// Older/local files are untrusted at replay too. Scrub text before it is
|
|
51
|
+
// expanded into terminal lines; never revive escapes from a JSON string.
|
|
52
|
+
return { ...row, kind: printableLine(safe(row.kind), 120), name: printableLine(safe(row.name), 120),
|
|
53
|
+
workId: printableLine(safe(row.workId), Infinity), turnId: printableLine(safe(row.turnId), Infinity),
|
|
54
|
+
...(row.agentId ? { agentId: printableLine(safe(row.agentId), Infinity) } : {}), text: safe(row.text) };
|
|
55
|
+
} catch { return false; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createDaemonJournal(path, { onFailure = () => {} } = {}) {
|
|
59
|
+
let missed = false;
|
|
60
|
+
const append = (raw) => {
|
|
61
|
+
let fd = null;
|
|
62
|
+
try {
|
|
63
|
+
let chunk = Buffer.from(raw);
|
|
64
|
+
const tail = '\n[truncated]\n';
|
|
65
|
+
if (chunk.length > MAX_DAEMON_LOG_BYTES)
|
|
66
|
+
chunk = Buffer.from(utf8Prefix(raw, MAX_DAEMON_LOG_BYTES - Buffer.byteLength(tail)) + tail);
|
|
67
|
+
const gap = missed ? Buffer.from('\n' + DETAIL_GAP + '\n') : Buffer.alloc(0);
|
|
68
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
69
|
+
const open = () => openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
70
|
+
fd = open();
|
|
71
|
+
fchmodSync(fd, 0o600);
|
|
72
|
+
if (!fstatSync(fd).isFile()) throw new Error('The local log is not a regular file');
|
|
73
|
+
if (fstatSync(fd).size + gap.length + chunk.length > MAX_DAEMON_LOG_BYTES) {
|
|
74
|
+
closeSync(fd); fd = null;
|
|
75
|
+
renameSync(path, path + '.1');
|
|
76
|
+
fd = open();
|
|
77
|
+
fchmodSync(fd, 0o600);
|
|
78
|
+
}
|
|
79
|
+
// A gap is a separate bounded entry, so even a file-sized console line
|
|
80
|
+
// cannot make a recovered log exceed its cap.
|
|
81
|
+
if (gap.length && gap.length + chunk.length <= MAX_DAEMON_LOG_BYTES) appendFileSync(fd, gap);
|
|
82
|
+
else if (gap.length) {
|
|
83
|
+
appendFileSync(fd, gap); closeSync(fd); fd = null;
|
|
84
|
+
renameSync(path, path + '.1'); fd = open(); fchmodSync(fd, 0o600);
|
|
85
|
+
}
|
|
86
|
+
appendFileSync(fd, chunk);
|
|
87
|
+
missed = false;
|
|
88
|
+
return true;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!missed) try { onFailure(error); } catch { /* diagnostic only */ }
|
|
91
|
+
missed = true;
|
|
92
|
+
return false;
|
|
93
|
+
} finally { if (fd !== null) try { closeSync(fd); } catch { /* a log close never stops work */ } }
|
|
94
|
+
};
|
|
95
|
+
return {
|
|
96
|
+
console: text => append(safe(text) + '\n'),
|
|
97
|
+
record(record) {
|
|
98
|
+
try { return append(encodeDetailRecord(record)); }
|
|
99
|
+
catch (error) { if (!missed) try { onFailure(error); } catch { /* diagnostic only */ } missed = true; return false; }
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|