@faithfulalabi/agent-lens 0.1.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 +124 -0
- package/bin/agent-lens.js +40 -0
- package/bin/package.json +4 -0
- package/dist/src/archive/cron-log.js +41 -0
- package/dist/src/archive/discover.js +89 -0
- package/dist/src/archive/index.js +7 -0
- package/dist/src/archive/lock.js +119 -0
- package/dist/src/archive/log.js +15 -0
- package/dist/src/archive/mirror.js +366 -0
- package/dist/src/archive/paths.js +159 -0
- package/dist/src/archive/read.js +133 -0
- package/dist/src/archive/report.js +272 -0
- package/dist/src/archive/seal.js +76 -0
- package/dist/src/archive/sidecar.js +39 -0
- package/dist/src/cli/args.js +47 -0
- package/dist/src/cli/commands/archive.js +65 -0
- package/dist/src/cli/commands/doctor.js +191 -0
- package/dist/src/cli/commands/prune.js +159 -0
- package/dist/src/cli/commands/rebuild.js +98 -0
- package/dist/src/cli/commands/schedule.js +325 -0
- package/dist/src/cli/commands/start.js +83 -0
- package/dist/src/cli/commands/warm.js +96 -0
- package/dist/src/cli/index.js +102 -0
- package/dist/src/content/resolve.js +163 -0
- package/dist/src/corpus/env.js +32 -0
- package/dist/src/corpus/paths.js +70 -0
- package/dist/src/corpus/scan.js +85 -0
- package/dist/src/corpus/watch.js +189 -0
- package/dist/src/db/freshness.js +82 -0
- package/dist/src/db/open.js +74 -0
- package/dist/src/db/read.js +266 -0
- package/dist/src/db/schema.js +312 -0
- package/dist/src/db/sidecars.js +216 -0
- package/dist/src/db/spill-index.js +68 -0
- package/dist/src/db/write.js +279 -0
- package/dist/src/project/pipeline.js +307 -0
- package/dist/src/project/subagents.js +41 -0
- package/dist/src/project/tools.js +94 -0
- package/dist/src/server/api.js +249 -0
- package/dist/src/server/app.js +28 -0
- package/dist/src/server/config.js +24 -0
- package/dist/src/server/drift-report.js +35 -0
- package/dist/src/server/index.js +1 -0
- package/dist/src/server/live.js +109 -0
- package/dist/src/server/middleware/host-guard.js +42 -0
- package/dist/src/server/middleware/token-auth.js +19 -0
- package/dist/src/server/start.js +150 -0
- package/dist/src/server/static-ui.js +97 -0
- package/dist/src/server/stream.js +50 -0
- package/dist/src/server/warm.js +48 -0
- package/dist/src/shared/api.js +1 -0
- package/dist/src/shared/entities.js +1 -0
- package/dist/src/shared/index.js +2 -0
- package/dist/src/shared/pricing.js +68 -0
- package/dist/src/shared/token.js +39 -0
- package/dist/src/transcript/accessors.js +28 -0
- package/dist/src/transcript/agents.js +44 -0
- package/dist/src/transcript/blocks.js +75 -0
- package/dist/src/transcript/drift.js +42 -0
- package/dist/src/transcript/human.js +65 -0
- package/dist/src/transcript/line.js +251 -0
- package/dist/src/transcript/raw-types.js +1 -0
- package/dist/src/transcript/spill.js +122 -0
- package/dist/src/transcript/usage.js +63 -0
- package/dist/src/transcript/version.js +1 -0
- package/package.json +70 -0
- package/ui/dist/assets/index-CKKoKUCq.js +254 -0
- package/ui/dist/assets/index-Chza4fL6.css +1 -0
- package/ui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/ui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/ui/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/ui/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/ui/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/ui/dist/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
- package/ui/dist/index.html +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Faithful Alabi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# agent-lens
|
|
2
|
+
|
|
3
|
+
A local-first tracing platform for coding agents. Run one command, open a local UI, and read back
|
|
4
|
+
everything a Claude Code session did — prompts, tool calls, sub-agents, inputs and outputs — so you
|
|
5
|
+
can see where a session went and steer the next one better.
|
|
6
|
+
|
|
7
|
+
It reads the transcripts Claude Code already writes to disk. There is nothing to add to your
|
|
8
|
+
harness and nothing to configure.
|
|
9
|
+
|
|
10
|
+
## Quickstart
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx @faithfulalabi/agent-lens
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
That starts the local server and the UI and prints the URL. Open it. Sessions you have already run
|
|
17
|
+
are there; new ones show up as they happen.
|
|
18
|
+
|
|
19
|
+
Requires Node.js `>=24`.
|
|
20
|
+
|
|
21
|
+
## The durability contract — read this before you delete anything
|
|
22
|
+
|
|
23
|
+
Three directories, three completely different promises.
|
|
24
|
+
|
|
25
|
+
| Path | What it is | What deleting it costs |
|
|
26
|
+
| ------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
27
|
+
| `~/.claude/projects` | The **source**. Claude Code owns it, writes it, and expires files from it on its own schedule. agent-lens only reads. | Not your call — Claude Code is already doing it. That is the whole reason this tool exists. |
|
|
28
|
+
| `~/.agent-lens/archive` | The **system of record**. A verbatim mirror: the same bytes at a different path, append-only, never written back. | **Everything past the source's cliff.** There is no other copy. |
|
|
29
|
+
| `~/.agent-lens/cache.db` | A **disposable** cache — search indexes and projections, all derived. | Nothing. It rebuilds from the archive. |
|
|
30
|
+
|
|
31
|
+
> **rm cache.db loses nothing. rm -rf ~/.agent-lens/archive loses data permanently.**
|
|
32
|
+
|
|
33
|
+
The inversion is the point. The directory that looks canonical is the one being erased, and the
|
|
34
|
+
unremarkable one in your home directory is the one holding the only surviving copy.
|
|
35
|
+
|
|
36
|
+
### The limitation, stated plainly
|
|
37
|
+
|
|
38
|
+
> **agent-lens can only archive what exists while it runs — a gap in uptime is a gap in the record.**
|
|
39
|
+
|
|
40
|
+
A coverage figure of 100% is 100% _of the survivors_. Anything Claude Code expired before the
|
|
41
|
+
archive existed, or during a long gap in it, is gone and no tool can bring it back.
|
|
42
|
+
|
|
43
|
+
What the archive does hold, it holds forever. The shape is consistent even though the numbers are
|
|
44
|
+
not: the source thins out with age and then stops — past a cliff a few weeks back there is nothing
|
|
45
|
+
left in it at all — while the archive keeps going. Every session older than that cliff exists only
|
|
46
|
+
in the archive.
|
|
47
|
+
|
|
48
|
+
Do not take a number from this page. Your corpus is not the author's, and both move day to day.
|
|
49
|
+
`agent-lens doctor` prints yours, including how many files have no live source left and are held
|
|
50
|
+
only by the archive.
|
|
51
|
+
|
|
52
|
+
## Keeping the archive current
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
agent-lens schedule install
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
On macOS that sets up a launchd job that runs `agent-lens archive` every 15 minutes and logs each
|
|
59
|
+
pass where `agent-lens doctor` reads it. Run it again any time — it replaces its own job rather
|
|
60
|
+
than duplicating it. `agent-lens schedule status` reports the job and its last successful pass;
|
|
61
|
+
`agent-lens schedule disable` turns it off cleanly, leaving the archive and its logs untouched. On
|
|
62
|
+
other platforms, run `agent-lens archive` every ~15 minutes yourself via a systemd timer or cron.
|
|
63
|
+
|
|
64
|
+
One caveat worth knowing before you rely on an interval: a wall-clock schedule does not fire while
|
|
65
|
+
the machine is asleep. Treat the interval as a bound on _wake_ time, not on elapsed time.
|
|
66
|
+
|
|
67
|
+
The pass is safe to run often: an advisory lock means a second concurrent pass copies nothing and
|
|
68
|
+
exits 0, and an unchanged corpus copies zero bytes. When a source has been rewritten, the archived
|
|
69
|
+
bytes are **kept** and the file is marked `diverged` rather than overwritten. `agent-lens archive
|
|
70
|
+
--verify` is the full-file integrity audit — **not for the scheduled pass**; run it by hand or on a
|
|
71
|
+
weekly schedule.
|
|
72
|
+
|
|
73
|
+
## `agent-lens doctor` — what is protected, and what is not
|
|
74
|
+
|
|
75
|
+
`doctor` reads both trees and reports archive coverage, integrity, total archive bytes split hot vs
|
|
76
|
+
sealed, every diverged file, and Claude Code's own retention setting. It **writes nothing** — not
|
|
77
|
+
the archive, not the log, not the lock, and never anything belonging to your harness. It reports
|
|
78
|
+
retention; it does not repair it.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
agent-lens doctor --verify
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
| Flag | Meaning |
|
|
85
|
+
| ------------------------ | --------------------------------------------------------------------------------------------- |
|
|
86
|
+
| `--json` | emit the full report as one JSON line |
|
|
87
|
+
| `--dataDir <dir>` | override `~/.agent-lens` |
|
|
88
|
+
| `--transcriptRoot <dir>` | override `~/.claude/projects` |
|
|
89
|
+
| `--settingsPath <file>` | override where the harness retention setting is read from (also `AGENT_LENS_CLAUDE_SETTINGS`) |
|
|
90
|
+
| `--verify` | recompute the full prefix hash — the real audit, not the sampled one |
|
|
91
|
+
|
|
92
|
+
Read the integrity line carefully. It prints three counts that sum to the number of archived files:
|
|
93
|
+
**verified**, **diverged** and **unverifiable**. A file is unverifiable when nothing exists to check
|
|
94
|
+
it against — its source has already expired, or it is sealed — and there is no stored per-file hash
|
|
95
|
+
yet. Those files are never counted as verified. On a machine that has been off for a month, expect
|
|
96
|
+
unverifiable to be the large one. That is the truthful answer, not a failure.
|
|
97
|
+
|
|
98
|
+
`doctor` also reports the time since the archive job's last successful pass, read from the job's
|
|
99
|
+
own log (`~/.agent-lens/logs/cron.log`) and never from a file mtime — a job that has not run is a
|
|
100
|
+
different, more urgent fact than one that ran and found nothing new to copy.
|
|
101
|
+
|
|
102
|
+
## Starting the server
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
agent-lens start
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
| Flag | Meaning |
|
|
109
|
+
| --------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
110
|
+
| `--port <n>` | bind port (auto-increments on collision) |
|
|
111
|
+
| `--host <host>` | bind host. Defaults to loopback; anything else prints a network-exposure warning and is your decision to make |
|
|
112
|
+
|
|
113
|
+
The server binds `127.0.0.1` by default and every `/api/*` request carries a token. See
|
|
114
|
+
[SECURITY.md](SECURITY.md) for the trust boundary, including the one property that surprises
|
|
115
|
+
people: on a shared machine, the agent being traced can read the trace API too.
|
|
116
|
+
|
|
117
|
+
## Contributing
|
|
118
|
+
|
|
119
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup, the render loop against a real corpus, and the
|
|
120
|
+
one-door rule every change to transcript reading has to satisfy.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The published entry point. It runs the BUILT CLI in-process: no transpiler, no
|
|
3
|
+
// source tree, no child process.
|
|
4
|
+
//
|
|
5
|
+
// In-process is load-bearing, not a tidy-up. The previous shim launched a child
|
|
6
|
+
// with a transpiler forking underneath it: a three-process chain that swallowed
|
|
7
|
+
// signals, recorded at src/cli/__tests__/start-signals.test.ts:7-12. Calling
|
|
8
|
+
// `main` here puts SIGINT/SIGTERM/SIGHUP straight onto the handlers
|
|
9
|
+
// `commands/start` registers, so `agent-lens start` survives a real kill.
|
|
10
|
+
//
|
|
11
|
+
// The tokens this file must never contain are therefore spelled nowhere in it,
|
|
12
|
+
// prose included — the render gate's own precedent for a substring pin, since a
|
|
13
|
+
// comment satisfies `toContain` just as well as code does. The list lives in
|
|
14
|
+
// src/packaging.test.ts, under "resolves the built CLI in-process".
|
|
15
|
+
//
|
|
16
|
+
// The UI still resolves: `resolveUiDir` in src/server/static-ui walks up to the
|
|
17
|
+
// nearest package.json instead of using a fixed relative path, precisely because
|
|
18
|
+
// the built server sits one directory deeper than the source one.
|
|
19
|
+
|
|
20
|
+
const MIN_NODE_MAJOR = 24;
|
|
21
|
+
const major = Number.parseInt(process.versions.node, 10);
|
|
22
|
+
|
|
23
|
+
if (!Number.isInteger(major) || major < MIN_NODE_MAJOR) {
|
|
24
|
+
// Ahead of the import, never after it. The CLI graph reaches node:sqlite
|
|
25
|
+
// (src/db/open) and zstd (src/archive/seal), so on an older Node the first
|
|
26
|
+
// symptom would be ERR_UNKNOWN_BUILTIN_MODULE from deep inside the DB layer.
|
|
27
|
+
// npm will not catch it either: `engine-strict` defaults to false, which makes
|
|
28
|
+
// the `engines` field a warning that installs anyway.
|
|
29
|
+
process.stderr.write(
|
|
30
|
+
`agent-lens needs Node 24 or newer; this is Node ${process.versions.node}.\n`,
|
|
31
|
+
);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
} else {
|
|
34
|
+
// Dynamic, so the guard above runs first — a static import would hoist past it.
|
|
35
|
+
const { main } = await import('../dist/src/cli/index.js');
|
|
36
|
+
// Setting the code and returning, rather than terminating the process here:
|
|
37
|
+
// the abrupt form truncates piped stdout mid-write. Exit codes 0/1/3 from
|
|
38
|
+
// `commands/archive` flow through unchanged either way.
|
|
39
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
40
|
+
}
|
package/bin/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { lstatSafe, resolveCronLogPath } from './paths.js';
|
|
3
|
+
const ENTRY = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2})(\d{2})\s+(\S+)\s*(.*)$/;
|
|
4
|
+
export function parseCronLog(text) {
|
|
5
|
+
const entries = [];
|
|
6
|
+
for (const line of text.split('\n')) {
|
|
7
|
+
const match = ENTRY.exec(line);
|
|
8
|
+
if (match === null)
|
|
9
|
+
continue;
|
|
10
|
+
const [, y, mo, d, h, mi, s, sign, offH, offM, status, summary] = match;
|
|
11
|
+
const offsetMs = (sign === '-' ? -1 : 1) * (Number(offH) * 60 + Number(offM)) * 60_000;
|
|
12
|
+
entries.push({
|
|
13
|
+
epochMs: Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)) - offsetMs,
|
|
14
|
+
status: status,
|
|
15
|
+
summary: summary?.trim() ?? '',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return entries;
|
|
19
|
+
}
|
|
20
|
+
export function readCronLogStatus(dataDir) {
|
|
21
|
+
const path = resolveCronLogPath(dataDir);
|
|
22
|
+
if (lstatSafe(path)?.isFile() !== true)
|
|
23
|
+
return { state: 'absent', path };
|
|
24
|
+
let text;
|
|
25
|
+
try {
|
|
26
|
+
text = readFileSync(path, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return { state: 'empty', path };
|
|
30
|
+
}
|
|
31
|
+
const entries = parseCronLog(text);
|
|
32
|
+
const lastEntry = entries.at(-1);
|
|
33
|
+
if (lastEntry === undefined)
|
|
34
|
+
return { state: 'empty', path };
|
|
35
|
+
return {
|
|
36
|
+
state: 'found',
|
|
37
|
+
path,
|
|
38
|
+
lastEntry,
|
|
39
|
+
lastOk: entries.findLast((entry) => entry.status === 'ok'),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { relativeUnder } from './paths.js';
|
|
4
|
+
const SESSION_SUBDIRS = ['subagents', 'tool-results'];
|
|
5
|
+
const ALLOWED_SUFFIXES = ['.jsonl', '.meta.json', '.txt'];
|
|
6
|
+
const SEALED_SUFFIX = '.zst';
|
|
7
|
+
function readDirSafe(dir, wantDirs) {
|
|
8
|
+
try {
|
|
9
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
10
|
+
.filter((entry) => (wantDirs ? entry.isDirectory() : entry.isFile()))
|
|
11
|
+
.map((entry) => entry.name);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function isAllowed(name) {
|
|
18
|
+
return ALLOWED_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
19
|
+
}
|
|
20
|
+
function walkRecursive(dir, stripSealed, out) {
|
|
21
|
+
for (const name of readDirSafe(dir, false)) {
|
|
22
|
+
if (isAllowed(stripSealed ? stripSealedSuffix(name).name : name)) {
|
|
23
|
+
out.push(join(dir, name));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const child of readDirSafe(dir, true)) {
|
|
27
|
+
walkRecursive(join(dir, child), stripSealed, out);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function stripSealedSuffix(name) {
|
|
31
|
+
return name.endsWith(SEALED_SUFFIX)
|
|
32
|
+
? { name: name.slice(0, -SEALED_SUFFIX.length), sealed: true }
|
|
33
|
+
: { name, sealed: false };
|
|
34
|
+
}
|
|
35
|
+
function walkRoot(root, stripSealed) {
|
|
36
|
+
const found = [];
|
|
37
|
+
for (const slug of readDirSafe(root, true)) {
|
|
38
|
+
const slugDir = join(root, slug);
|
|
39
|
+
for (const name of readDirSafe(slugDir, false)) {
|
|
40
|
+
const stem = stripSealed ? stripSealedSuffix(name).name : name;
|
|
41
|
+
if (stem.endsWith('.jsonl'))
|
|
42
|
+
found.push(join(slugDir, name));
|
|
43
|
+
}
|
|
44
|
+
for (const session of readDirSafe(slugDir, true)) {
|
|
45
|
+
for (const sub of SESSION_SUBDIRS) {
|
|
46
|
+
walkRecursive(join(slugDir, session, sub), stripSealed, found);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
export function discover(sourceRoot, archiveRoot) {
|
|
53
|
+
const entries = new Map();
|
|
54
|
+
for (const sourcePath of walkRoot(sourceRoot, false)) {
|
|
55
|
+
const relPath = relativeUnder(sourceRoot, sourcePath);
|
|
56
|
+
if (relPath === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
entries.set(relPath, {
|
|
59
|
+
relPath,
|
|
60
|
+
sourcePath,
|
|
61
|
+
archivePath: join(archiveRoot, relPath),
|
|
62
|
+
presence: 'source-only',
|
|
63
|
+
sealed: false,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
for (const found of walkRoot(archiveRoot, true)) {
|
|
67
|
+
const { sealed } = stripSealedSuffix(found);
|
|
68
|
+
const archivePath = sealed ? found.slice(0, -SEALED_SUFFIX.length) : found;
|
|
69
|
+
const relPath = relativeUnder(archiveRoot, archivePath);
|
|
70
|
+
if (relPath === undefined)
|
|
71
|
+
continue;
|
|
72
|
+
const sourcePath = join(sourceRoot, relPath);
|
|
73
|
+
const existing = entries.get(relPath);
|
|
74
|
+
if (existing === undefined) {
|
|
75
|
+
entries.set(relPath, {
|
|
76
|
+
relPath,
|
|
77
|
+
sourcePath,
|
|
78
|
+
archivePath,
|
|
79
|
+
presence: 'archive-only',
|
|
80
|
+
sealed,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
existing.presence = 'both';
|
|
85
|
+
existing.sealed ||= sealed;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [...entries.values()].sort((a, b) => (a.relPath < b.relPath ? -1 : 1));
|
|
89
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { archiveOnce, createMirrorContext, mirrorFile } from './mirror.js';
|
|
2
|
+
export { discover } from './discover.js';
|
|
3
|
+
export { buildDoctorReport, SEALED_LEGACY_REASON } from './report.js';
|
|
4
|
+
export { acquireLock } from './lock.js';
|
|
5
|
+
export { parseCronLog, readCronLogStatus } from './cron-log.js';
|
|
6
|
+
export { resolveArchiveLogPath, resolveArchiveRoot, resolveCronLogPath, resolveDataDir, resolveLockPath, resolveTranscriptRoot, } from './paths.js';
|
|
7
|
+
export { assertNotUnderRoot, assertUnderRoot, DATA_DIR_LABEL, TRANSCRIPT_ROOT_LABEL, } from './paths.js';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, unlinkSync, writeSync } from 'node:fs';
|
|
2
|
+
import { hostname as osHostname } from 'node:os';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { assertUnderRoot, DATA_DIR_LABEL, ensureDirUnder, resolveLockPath, statSafe, } from './paths.js';
|
|
5
|
+
export const MAX_LOCK_AGE_MS = 60 * 60 * 1000;
|
|
6
|
+
function defaultIsAlive(pid) {
|
|
7
|
+
try {
|
|
8
|
+
process.kill(pid, 0);
|
|
9
|
+
return 'alive';
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
return error.code === 'EPERM' ? 'permission-denied' : 'dead';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function parseRecord(raw) {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(raw);
|
|
18
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
19
|
+
return undefined;
|
|
20
|
+
const { pid, started_at, hostname } = parsed;
|
|
21
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0)
|
|
22
|
+
return undefined;
|
|
23
|
+
return {
|
|
24
|
+
pid,
|
|
25
|
+
started_at: typeof started_at === 'number' ? started_at : 0,
|
|
26
|
+
hostname: typeof hostname === 'string' ? hostname : '',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function tryCreate(lockPath, record) {
|
|
34
|
+
let fd;
|
|
35
|
+
try {
|
|
36
|
+
fd = openSync(lockPath, 'wx', 0o600);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code === 'EEXIST')
|
|
40
|
+
return false;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
writeSync(fd, JSON.stringify(record));
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
export function releaseLock(lockPath, pid) {
|
|
52
|
+
try {
|
|
53
|
+
const record = parseRecord(readFileSync(lockPath, 'utf8'));
|
|
54
|
+
if (record?.pid === pid)
|
|
55
|
+
unlinkSync(lockPath);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function acquireLock(dataDir, identity = {}, lockPath = resolveLockPath(dataDir)) {
|
|
61
|
+
const pid = identity.pid ?? process.pid;
|
|
62
|
+
const hostname = identity.hostname ?? osHostname();
|
|
63
|
+
const now = identity.now ?? Date.now();
|
|
64
|
+
const isAlive = identity.isAlive ?? defaultIsAlive;
|
|
65
|
+
ensureDirUnder(dirname(lockPath), dataDir, DATA_DIR_LABEL);
|
|
66
|
+
assertUnderRoot(lockPath, dataDir, DATA_DIR_LABEL);
|
|
67
|
+
const mine = { pid, started_at: now, hostname };
|
|
68
|
+
if (tryCreate(lockPath, mine)) {
|
|
69
|
+
return {
|
|
70
|
+
state: { state: 'acquired', holder_pid: pid },
|
|
71
|
+
release: () => releaseLock(lockPath, pid),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = readFileSync(lockPath, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return tryCreate(lockPath, mine)
|
|
80
|
+
? { state: { state: 'acquired', holder_pid: pid }, release: () => releaseLock(lockPath, pid) }
|
|
81
|
+
: { state: { state: 'held' }, release: () => { } };
|
|
82
|
+
}
|
|
83
|
+
const record = parseRecord(raw);
|
|
84
|
+
const held = (extra) => ({
|
|
85
|
+
state: { state: 'held', ...extra },
|
|
86
|
+
release: () => { },
|
|
87
|
+
});
|
|
88
|
+
if (record === undefined) {
|
|
89
|
+
const mtime = statSafe(lockPath)?.mtimeMs ?? 0;
|
|
90
|
+
const age = now - mtime;
|
|
91
|
+
if (age <= MAX_LOCK_AGE_MS)
|
|
92
|
+
return held({ age_ms: age });
|
|
93
|
+
return reclaim(lockPath, mine, 'unparseable', undefined, age);
|
|
94
|
+
}
|
|
95
|
+
const age = now - record.started_at;
|
|
96
|
+
const liveness = isAlive(record.pid);
|
|
97
|
+
if (liveness === 'permission-denied')
|
|
98
|
+
return held({ holder_pid: record.pid, age_ms: age });
|
|
99
|
+
if (liveness === 'alive' && record.hostname === hostname) {
|
|
100
|
+
return held({ holder_pid: record.pid, age_ms: age });
|
|
101
|
+
}
|
|
102
|
+
if (liveness === 'dead')
|
|
103
|
+
return reclaim(lockPath, mine, 'esrch', record.pid, age);
|
|
104
|
+
return reclaim(lockPath, mine, 'foreign-host', record.pid, age);
|
|
105
|
+
}
|
|
106
|
+
function reclaim(lockPath, mine, reason, holderPid, ageMs) {
|
|
107
|
+
try {
|
|
108
|
+
unlinkSync(lockPath);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
}
|
|
112
|
+
if (!tryCreate(lockPath, mine)) {
|
|
113
|
+
return { state: { state: 'held', holder_pid: holderPid, age_ms: ageMs }, release: () => { } };
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
state: { state: 'reclaimed', holder_pid: holderPid, age_ms: ageMs, reclaim_reason: reason },
|
|
117
|
+
release: () => releaseLock(lockPath, mine.pid),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { appendOwnedLine, resolveArchiveLogPath } from './paths.js';
|
|
2
|
+
export function isQuiet(record) {
|
|
3
|
+
return (record.bytes_copied === 0 &&
|
|
4
|
+
record.diverged.length === 0 &&
|
|
5
|
+
record.newly_expired.length === 0 &&
|
|
6
|
+
record.errors.length === 0 &&
|
|
7
|
+
record.lock.state === 'acquired' &&
|
|
8
|
+
record.sealed.length === 0);
|
|
9
|
+
}
|
|
10
|
+
export function appendArchiveLog(dataDir, record) {
|
|
11
|
+
if (isQuiet(record))
|
|
12
|
+
return false;
|
|
13
|
+
appendOwnedLine(resolveArchiveLogPath(dataDir), `${JSON.stringify(record)}\n`, dataDir);
|
|
14
|
+
return true;
|
|
15
|
+
}
|