@fitchmultz/pi-subagents 0.50.0 → 0.51.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/CHANGELOG.md +51 -2
- package/README.md +36 -5
- package/dist/agent/contracts.js +6 -24
- package/dist/agent/host.js +3 -16
- package/dist/agent/index.js +207 -173
- package/dist/agent/journal-tail.js +115 -156
- package/dist/agent/launch.js +20 -8
- package/dist/agent/legacy.js +65 -23
- package/dist/agent/notifications.js +138 -47
- package/dist/agent/observation-state.js +246 -0
- package/dist/agent/observation-view.js +258 -0
- package/dist/agent/observation.js +346 -0
- package/dist/agent/observe-card.js +243 -0
- package/dist/agent/observe-chrome.js +85 -0
- package/dist/agent/observe-client.js +285 -0
- package/dist/agent/observe-layout.js +65 -0
- package/dist/agent/observe-reading.js +84 -0
- package/dist/agent/observe-screen.js +400 -96
- package/dist/agent/observe-transcript.js +382 -0
- package/dist/agent/observe.js +250 -43
- package/dist/agent/process.js +198 -25
- package/dist/agent/receipts.js +92 -20
- package/dist/agent/runner.js +125 -20
- package/dist/agent/shell.js +2 -1
- package/dist/agent/storage.js +29 -12
- package/dist/agent/tool-schemas.js +20 -0
- package/dist/agent/typebox.js +10 -5
- package/dist/agent/usage.js +0 -115
- package/dist/agent/view.js +46 -16
- package/dist/agent/wake.js +99 -0
- package/dist/build-info.js +1 -1
- package/dist/intercom-client.d.ts +26 -0
- package/dist/intercom-client.js +45 -0
- package/dist/pi-intercom/agent-dir.d.ts +1 -0
- package/dist/pi-intercom/bridge-session.js +2 -1
- package/dist/pi-intercom/broker/broker.js +27 -21
- package/dist/pi-intercom/broker/client-last.d.ts +13 -0
- package/dist/pi-intercom/broker/client-last.js +17 -5
- package/dist/pi-intercom/broker/client.d.ts +60 -0
- package/dist/pi-intercom/broker/client.js +47 -16
- package/dist/pi-intercom/broker/connection.d.ts +29 -0
- package/dist/pi-intercom/broker/framing.d.ts +17 -0
- package/dist/pi-intercom/broker/framing.js +10 -7
- package/dist/pi-intercom/broker/guard.js +23 -0
- package/dist/pi-intercom/broker/last.d.ts +26 -0
- package/dist/pi-intercom/broker/last.js +57 -32
- package/dist/pi-intercom/broker/paths.d.ts +13 -0
- package/dist/pi-intercom/broker/paths.js +35 -12
- package/dist/pi-intercom/broker/pid.js +5 -1
- package/dist/pi-intercom/broker/server-connection.js +19 -0
- package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
- package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
- package/dist/pi-intercom/broker/targets.d.ts +9 -0
- package/dist/pi-intercom/connection.js +10 -2
- package/dist/pi-intercom/identity.js +6 -5
- package/dist/pi-intercom/inbound-delivery.js +16 -60
- package/dist/pi-intercom/inbound-journal.js +83 -6
- package/dist/pi-intercom/inbound-record.js +18 -1
- package/dist/pi-intercom/inbound.js +12 -38
- package/dist/pi-intercom/index.js +3 -0
- package/dist/pi-intercom/last-message.js +4 -16
- package/dist/pi-intercom/lifecycle.js +88 -3
- package/dist/pi-intercom/outbound.js +5 -15
- package/dist/pi-intercom/reply-tracker.js +7 -6
- package/dist/pi-intercom/reply-wait.js +2 -2
- package/dist/pi-intercom/session-targets.d.ts +24 -0
- package/dist/pi-intercom/tool-registration.js +4 -1
- package/dist/pi-intercom/tool-results.js +5 -7
- package/dist/pi-intercom/tool-schemas.js +12 -26
- package/dist/pi-intercom/types.d.ts +212 -0
- package/dist/pi-intercom/types.js +54 -3
- package/dist/pi-intercom/validation.d.ts +12 -0
- package/dist/pi-intercom/validation.js +3 -0
- package/dist/shared/bounded-json.js +151 -0
- package/dist/shared/compact-cards.js +66 -0
- package/dist/shared/journal-frames.js +3 -6
- package/dist/shared/json-projection.js +267 -129
- package/dist/shared/native-journal.js +123 -56
- package/dist/shared/post-exit-stdio-guard.js +120 -7
- package/dist/shared/tui-action.js +29 -0
- package/dist/shared/unknown.d.ts +9 -0
- package/docs/code-quality.md +2 -0
- package/docs/development.md +40 -1
- package/docs/intercom-bridge.md +9 -4
- package/docs/intercom.md +226 -6
- package/docs/performance.md +87 -11
- package/docs/reference.md +157 -20
- package/docs/retired-history-maintenance.md +252 -0
- package/package.json +28 -15
- package/scripts/archive-retired-history.mjs +464 -0
- package/scripts/build.mjs +48 -28
- package/scripts/measure-size.mjs +1 -1
- package/scripts/native-package-smoke.mjs +153 -12
- package/scripts/package-smoke.mjs +80 -11
- package/scripts/prepare.mjs +2 -2
- package/scripts/real-pi-live.mjs +377 -0
- package/scripts/real-pi-processes.mjs +6 -1
- package/scripts/real-pi-smoke.mjs +6 -282
- package/scripts/run-tests.mjs +4 -2
- package/scripts/typescript.mjs +35 -0
- package/skills/pi-intercom/SKILL.md +2 -1
- package/skills/pi-subagents/SKILL.md +23 -5
- package/src/agent/contracts.ts +8 -34
- package/src/agent/host.ts +19 -20
- package/src/agent/index.ts +224 -190
- package/src/agent/journal-tail.ts +141 -153
- package/src/agent/launch.ts +33 -9
- package/src/agent/legacy.ts +85 -31
- package/src/agent/notifications.ts +192 -67
- package/src/agent/observation-state.ts +277 -0
- package/src/agent/observation-view.ts +267 -0
- package/src/agent/observation.ts +405 -0
- package/src/agent/observe-card.ts +304 -0
- package/src/agent/observe-chrome.ts +170 -0
- package/src/agent/observe-client.ts +318 -0
- package/src/agent/observe-layout.ts +79 -0
- package/src/agent/observe-reading.ts +97 -0
- package/src/agent/observe-screen.ts +443 -129
- package/src/agent/observe-transcript.ts +434 -0
- package/src/agent/observe.ts +304 -71
- package/src/agent/process.ts +240 -34
- package/src/agent/receipts.ts +114 -24
- package/src/agent/runner.ts +125 -27
- package/src/agent/shell.ts +2 -1
- package/src/agent/storage.ts +27 -11
- package/src/agent/tool-schemas.ts +33 -0
- package/src/agent/typebox.ts +12 -7
- package/src/agent/usage.ts +0 -130
- package/src/agent/view.ts +70 -20
- package/src/agent/wake.ts +107 -0
- package/src/intercom-client.ts +108 -0
- package/src/pi-intercom/bridge-session.ts +2 -2
- package/src/pi-intercom/broker/broker.ts +27 -23
- package/src/pi-intercom/broker/client-last.ts +25 -12
- package/src/pi-intercom/broker/client.ts +55 -33
- package/src/pi-intercom/broker/framing.ts +11 -8
- package/src/pi-intercom/broker/guard.ts +33 -0
- package/src/pi-intercom/broker/last.ts +86 -33
- package/src/pi-intercom/broker/paths.ts +39 -12
- package/src/pi-intercom/broker/pid.ts +6 -1
- package/src/pi-intercom/broker/server-connection.ts +25 -0
- package/src/pi-intercom/connection.ts +21 -10
- package/src/pi-intercom/identity.ts +8 -5
- package/src/pi-intercom/inbound-delivery.ts +20 -66
- package/src/pi-intercom/inbound-journal.ts +103 -7
- package/src/pi-intercom/inbound-record.ts +21 -4
- package/src/pi-intercom/inbound.ts +18 -49
- package/src/pi-intercom/index.ts +3 -0
- package/src/pi-intercom/last-message.ts +10 -19
- package/src/pi-intercom/lifecycle.ts +117 -14
- package/src/pi-intercom/outbound.ts +7 -16
- package/src/pi-intercom/reply-tracker.ts +8 -6
- package/src/pi-intercom/reply-wait.ts +3 -2
- package/src/pi-intercom/runtime-types.ts +0 -1
- package/src/pi-intercom/tool-registration.ts +4 -1
- package/src/pi-intercom/tool-results.ts +10 -9
- package/src/pi-intercom/tool-schemas.ts +12 -44
- package/src/pi-intercom/types.ts +115 -4
- package/src/pi-intercom/validation.ts +4 -0
- package/src/shared/bounded-json.ts +162 -0
- package/src/shared/compact-cards.ts +118 -0
- package/src/shared/journal-frames.ts +3 -19
- package/src/shared/json-projection.ts +267 -147
- package/src/shared/native-journal.ts +157 -75
- package/src/shared/post-exit-stdio-guard.ts +165 -7
- package/src/shared/tui-action.ts +39 -0
- package/tsconfig.client.json +8 -0
- package/tsconfig.json +1 -0
- package/src/shared/stream-json.d.ts +0 -10
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { parseArgs, isDeepStrictEqual } from "node:util";
|
|
8
|
+
|
|
9
|
+
/** @typedef {Readonly<{version: 1, ownerUid: number, source: string, archive: string, sourceParent: unknown, archiveParent: unknown, container: unknown, directory: unknown, members: unknown}>} ArchiveRecord */
|
|
10
|
+
|
|
11
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
12
|
+
function isRecord(value) {
|
|
13
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const generation = /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}\.sqlite$/;
|
|
17
|
+
const database =
|
|
18
|
+
/^(?:[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}\.sqlite(?:\.ingest\.sqlite)?|run-timing\.sqlite)$/;
|
|
19
|
+
const manifest = /^[a-f0-9]{64}\.json$/;
|
|
20
|
+
const flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {unknown} value
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
function absolute(value) {
|
|
27
|
+
assert.ok(typeof value === "string" && path.isAbsolute(value), "E_ABSOLUTE_PATH");
|
|
28
|
+
assert.ok(path.resolve(value) === value, "E_CANONICAL_PATH");
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* @param {import("node:fs").BigIntStats} stat
|
|
33
|
+
*/
|
|
34
|
+
function identity(stat) {
|
|
35
|
+
return {
|
|
36
|
+
dev: String(stat.dev),
|
|
37
|
+
ino: String(stat.ino),
|
|
38
|
+
uid: String(stat.uid),
|
|
39
|
+
gid: String(stat.gid),
|
|
40
|
+
mode: String(stat.mode),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* @param {import("node:fs").BigIntStats} stat
|
|
45
|
+
*/
|
|
46
|
+
function stamp(stat) {
|
|
47
|
+
return {
|
|
48
|
+
...identity(stat),
|
|
49
|
+
size: String(stat.size),
|
|
50
|
+
nlink: String(stat.nlink),
|
|
51
|
+
mtimeNs: String(stat.mtimeNs),
|
|
52
|
+
ctimeNs: String(stat.ctimeNs),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* @param {import("node:fs").BigIntStats} stat
|
|
57
|
+
* @param {number} uid
|
|
58
|
+
*/
|
|
59
|
+
function owned(stat, uid) {
|
|
60
|
+
assert.ok(stat.uid === BigInt(uid), "E_FOREIGN_OWNER");
|
|
61
|
+
assert.ok((stat.mode & 0o022n) === 0n, "E_UNSAFE_MODE");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} file
|
|
65
|
+
* @param {number} uid
|
|
66
|
+
*/
|
|
67
|
+
function trustedChain(file, uid) {
|
|
68
|
+
let cursor = path.parse(file).root;
|
|
69
|
+
const root = fs.lstatSync(cursor, { bigint: true });
|
|
70
|
+
assert.ok(root.uid === 0n, "E_ANCESTOR_OWNER");
|
|
71
|
+
assert.ok((root.mode & 0o022n) === 0n, "E_ANCESTOR_WRITABLE");
|
|
72
|
+
for (const component of path.relative(cursor, file).split(path.sep)) {
|
|
73
|
+
cursor = path.join(cursor, component);
|
|
74
|
+
const stat = fs.lstatSync(cursor, { bigint: true });
|
|
75
|
+
assert.ok(stat.isDirectory() && !stat.isSymbolicLink(), "E_PATH_LINK");
|
|
76
|
+
assert.ok(stat.uid === 0n || stat.uid === BigInt(uid), "E_ANCESTOR_OWNER");
|
|
77
|
+
if ((stat.mode & 0o022n) !== 0n) {
|
|
78
|
+
assert.ok(stat.uid === 0n && (stat.mode & 0o1000n) !== 0n, "E_ANCESTOR_WRITABLE");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} file
|
|
84
|
+
* @param {number} uid
|
|
85
|
+
* @param {boolean} [privateOnly]
|
|
86
|
+
*/
|
|
87
|
+
function directory(file, uid, privateOnly = false) {
|
|
88
|
+
absolute(file);
|
|
89
|
+
trustedChain(file, uid);
|
|
90
|
+
assert.ok(fs.realpathSync(file) === file, "E_PATH_LINK");
|
|
91
|
+
const stat = fs.lstatSync(file, { bigint: true });
|
|
92
|
+
assert.ok(stat.isDirectory(), "E_DIRECTORY");
|
|
93
|
+
owned(stat, uid);
|
|
94
|
+
if (privateOnly) {
|
|
95
|
+
assert.ok((stat.mode & 0o077n) === 0n, "E_PRIVATE_DIRECTORY");
|
|
96
|
+
}
|
|
97
|
+
return stat;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} file
|
|
101
|
+
*/
|
|
102
|
+
function syncDirectory(file) {
|
|
103
|
+
const fd = fs.openSync(file, flags);
|
|
104
|
+
try {
|
|
105
|
+
fs.fsyncSync(fd);
|
|
106
|
+
} finally {
|
|
107
|
+
fs.closeSync(fd);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* @param {string} file
|
|
112
|
+
*/
|
|
113
|
+
function exists(file) {
|
|
114
|
+
return fs.lstatSync(file, { throwIfNoEntry: false }) !== undefined;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} file
|
|
118
|
+
* @param {string} root
|
|
119
|
+
*/
|
|
120
|
+
function outside(file, root) {
|
|
121
|
+
assert.ok(file !== root && !file.startsWith(`${root}${path.sep}`), "E_OVERLAPPING_PATHS");
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* @param {string} directoryPath
|
|
125
|
+
*/
|
|
126
|
+
function closed(directoryPath) {
|
|
127
|
+
const executable = process.platform === "darwin" ? "/usr/sbin/lsof" : "/usr/bin/lsof";
|
|
128
|
+
const result = spawnSync(executable, ["-nPl", "-F", "p", "+d", directoryPath], {
|
|
129
|
+
encoding: "utf8",
|
|
130
|
+
timeout: 30_000,
|
|
131
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
132
|
+
});
|
|
133
|
+
assert.ok(result.error === undefined, "E_LSOF_REQUIRED");
|
|
134
|
+
assert.ok(result.signal === null, "E_LSOF_INTERRUPTED");
|
|
135
|
+
assert.ok(result.stderr === "", "E_LSOF_INCOMPLETE");
|
|
136
|
+
assert.ok(result.stdout === "", "E_OPEN_HANDLES");
|
|
137
|
+
assert.ok(result.status === 1, "E_LSOF_RESULT");
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* @param {string} directoryPath
|
|
141
|
+
* @param {string} name
|
|
142
|
+
* @param {number} uid
|
|
143
|
+
*/
|
|
144
|
+
function member(directoryPath, name, uid) {
|
|
145
|
+
const base = name.replace(/-(?:wal|shm)$/, "");
|
|
146
|
+
assert.ok(database.test(base) || manifest.test(name), "E_UNKNOWN_MEMBER");
|
|
147
|
+
const file = path.join(directoryPath, name);
|
|
148
|
+
const before = fs.lstatSync(file, { bigint: true });
|
|
149
|
+
assert.ok(before.isFile() && before.nlink === 1n, "E_MEMBER_TYPE");
|
|
150
|
+
owned(before, uid);
|
|
151
|
+
const fd = fs.openSync(file, flags);
|
|
152
|
+
try {
|
|
153
|
+
assert.ok(
|
|
154
|
+
isDeepStrictEqual(stamp(fs.fstatSync(fd, { bigint: true })), stamp(before)),
|
|
155
|
+
"E_MEMBER_IDENTITY",
|
|
156
|
+
);
|
|
157
|
+
const sha256 = createHash("sha256");
|
|
158
|
+
const buffer = Buffer.alloc(1024 * 1024);
|
|
159
|
+
let length;
|
|
160
|
+
while ((length = fs.readSync(fd, buffer, 0, buffer.length, null)) !== 0) {
|
|
161
|
+
sha256.update(buffer.subarray(0, length));
|
|
162
|
+
}
|
|
163
|
+
assert.ok(
|
|
164
|
+
isDeepStrictEqual(stamp(fs.fstatSync(fd, { bigint: true })), stamp(before)),
|
|
165
|
+
"E_MEMBER_CHANGED",
|
|
166
|
+
);
|
|
167
|
+
assert.ok(
|
|
168
|
+
isDeepStrictEqual(stamp(fs.lstatSync(file, { bigint: true })), stamp(before)),
|
|
169
|
+
"E_MEMBER_PATH_CHANGED",
|
|
170
|
+
);
|
|
171
|
+
return { name, ...stamp(before), sha256: sha256.digest("hex") };
|
|
172
|
+
} finally {
|
|
173
|
+
fs.closeSync(fd);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* @param {string} directoryPath
|
|
178
|
+
* @param {number} uid
|
|
179
|
+
*/
|
|
180
|
+
function inventory(directoryPath, uid) {
|
|
181
|
+
directory(directoryPath, uid);
|
|
182
|
+
const names = fs.readdirSync(directoryPath).toSorted();
|
|
183
|
+
assert.ok(names.length > 0, "E_EMPTY_SET");
|
|
184
|
+
const members = names.map((name) => member(directoryPath, name, uid));
|
|
185
|
+
for (const name of names) {
|
|
186
|
+
const base = name.replace(/-(?:wal|shm)$/, "");
|
|
187
|
+
assert.ok(names.includes(base), "E_ORPHAN_COMPANION");
|
|
188
|
+
assert.ok(names.includes(`${base}-wal`) === names.includes(`${base}-shm`), "E_INCOMPLETE_WAL");
|
|
189
|
+
if (base.endsWith(".ingest.sqlite")) {
|
|
190
|
+
assert.ok(names.includes(base.replace(/\.ingest\.sqlite$/, "")), "E_ORPHAN_INGEST");
|
|
191
|
+
}
|
|
192
|
+
if (manifest.test(name)) {
|
|
193
|
+
const selected = readManifest(path.join(directoryPath, name));
|
|
194
|
+
assert.ok(names.includes(selected), "E_MISSING_GENERATION");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
assert.ok(
|
|
198
|
+
isDeepStrictEqual(fs.readdirSync(directoryPath).toSorted(), names),
|
|
199
|
+
"E_MEMBERSHIP_CHANGED",
|
|
200
|
+
);
|
|
201
|
+
return members;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* @param {string} file
|
|
205
|
+
*/
|
|
206
|
+
function readManifest(file) {
|
|
207
|
+
const fd = fs.openSync(file, flags);
|
|
208
|
+
try {
|
|
209
|
+
assert.ok(fs.fstatSync(fd).size <= 65_536, "E_MANIFEST_SIZE");
|
|
210
|
+
/** @type {unknown} */
|
|
211
|
+
const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
212
|
+
assert.ok(isRecord(parsed), "E_MANIFEST");
|
|
213
|
+
const selected = parsed.file;
|
|
214
|
+
assert.ok(typeof selected === "string" && generation.test(selected), "E_MANIFEST");
|
|
215
|
+
return selected;
|
|
216
|
+
} finally {
|
|
217
|
+
fs.closeSync(fd);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* @param {string} file
|
|
222
|
+
* @param {number} uid
|
|
223
|
+
* @returns {ArchiveRecord}
|
|
224
|
+
*/
|
|
225
|
+
function recordAt(file, uid) {
|
|
226
|
+
directory(path.dirname(absolute(file)), uid, true);
|
|
227
|
+
const fd = fs.openSync(file, flags);
|
|
228
|
+
try {
|
|
229
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
230
|
+
owned(stat, uid);
|
|
231
|
+
assert.ok(stat.isFile() && stat.nlink === 1n && stat.size <= 16_777_216n, "E_RECORD_TYPE");
|
|
232
|
+
assert.ok((stat.mode & 0o077n) === 0n, "E_PRIVATE_RECORD");
|
|
233
|
+
/** @type {unknown} */
|
|
234
|
+
const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
235
|
+
assert.ok(isRecord(parsed), "E_RECORD_TYPE");
|
|
236
|
+
assert.ok(parsed.version === 1, "E_RECORD_VERSION");
|
|
237
|
+
assert.ok(parsed.ownerUid === uid, "E_RECORD_OWNER");
|
|
238
|
+
/** @type {ArchiveRecord} */
|
|
239
|
+
const record = {
|
|
240
|
+
version: 1,
|
|
241
|
+
ownerUid: uid,
|
|
242
|
+
source: absolute(parsed.source),
|
|
243
|
+
archive: absolute(parsed.archive),
|
|
244
|
+
sourceParent: parsed.sourceParent,
|
|
245
|
+
archiveParent: parsed.archiveParent,
|
|
246
|
+
container: parsed.container,
|
|
247
|
+
directory: parsed.directory,
|
|
248
|
+
members: parsed.members,
|
|
249
|
+
};
|
|
250
|
+
assert.ok(
|
|
251
|
+
isDeepStrictEqual(stamp(fs.fstatSync(fd, { bigint: true })), stamp(stat)),
|
|
252
|
+
"E_RECORD_CHANGED",
|
|
253
|
+
);
|
|
254
|
+
assert.ok(
|
|
255
|
+
isDeepStrictEqual(stamp(fs.lstatSync(file, { bigint: true })), stamp(stat)),
|
|
256
|
+
"E_RECORD_PATH_CHANGED",
|
|
257
|
+
);
|
|
258
|
+
assert.ok(path.basename(record.source) === "history-index", "E_SOURCE_NAME");
|
|
259
|
+
outside(record.archive, record.source);
|
|
260
|
+
outside(file, record.source);
|
|
261
|
+
outside(file, record.archive);
|
|
262
|
+
return record;
|
|
263
|
+
} finally {
|
|
264
|
+
fs.closeSync(fd);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* @param {ArchiveRecord} record
|
|
269
|
+
* @param {number} uid
|
|
270
|
+
*/
|
|
271
|
+
function parents(record, uid) {
|
|
272
|
+
assert.ok(
|
|
273
|
+
isDeepStrictEqual(identity(directory(path.dirname(record.source), uid)), record.sourceParent),
|
|
274
|
+
"E_SOURCE_PARENT_CHANGED",
|
|
275
|
+
);
|
|
276
|
+
assert.ok(
|
|
277
|
+
isDeepStrictEqual(
|
|
278
|
+
identity(directory(path.dirname(record.archive), uid, true)),
|
|
279
|
+
record.archiveParent,
|
|
280
|
+
),
|
|
281
|
+
"E_ARCHIVE_PARENT_CHANGED",
|
|
282
|
+
);
|
|
283
|
+
assert.ok(
|
|
284
|
+
isDeepStrictEqual(identity(directory(record.archive, uid, true)), record.container),
|
|
285
|
+
"E_CONTAINER_CHANGED",
|
|
286
|
+
);
|
|
287
|
+
assert.ok(
|
|
288
|
+
directory(path.dirname(record.source), uid).dev === directory(record.archive, uid, true).dev,
|
|
289
|
+
"E_CROSS_DEVICE",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* @param {ArchiveRecord} record
|
|
294
|
+
* @param {number} uid
|
|
295
|
+
*/
|
|
296
|
+
function inspect(record, uid) {
|
|
297
|
+
parents(record, uid);
|
|
298
|
+
const destination = path.join(record.archive, "history-index");
|
|
299
|
+
const sourceExists = exists(record.source);
|
|
300
|
+
const destinationExists = exists(destination);
|
|
301
|
+
assert.ok(sourceExists !== destinationExists, "E_AMBIGUOUS_PATHS");
|
|
302
|
+
assert.ok(
|
|
303
|
+
isDeepStrictEqual(
|
|
304
|
+
fs.readdirSync(record.archive).toSorted(),
|
|
305
|
+
destinationExists ? ["history-index"] : [],
|
|
306
|
+
),
|
|
307
|
+
"E_UNEXPECTED_ARCHIVE_CONTAINER_CONTENTS",
|
|
308
|
+
);
|
|
309
|
+
const location = sourceExists ? record.source : destination;
|
|
310
|
+
assert.ok(
|
|
311
|
+
isDeepStrictEqual(identity(directory(location, uid)), record.directory),
|
|
312
|
+
"E_SOURCE_CHANGED",
|
|
313
|
+
);
|
|
314
|
+
closed(location);
|
|
315
|
+
assert.ok(isDeepStrictEqual(inventory(location, uid), record.members), "E_INVENTORY_CHANGED");
|
|
316
|
+
closed(location);
|
|
317
|
+
parents(record, uid);
|
|
318
|
+
return sourceExists ? "prepared" : "archived";
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* @param {string} file
|
|
322
|
+
* @param {ArchiveRecord} record
|
|
323
|
+
* @param {number} uid
|
|
324
|
+
* @param {number} gid
|
|
325
|
+
*/
|
|
326
|
+
function saveRecord(file, record, uid, gid) {
|
|
327
|
+
const fd = fs.openSync(
|
|
328
|
+
file,
|
|
329
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
|
|
330
|
+
0o600,
|
|
331
|
+
);
|
|
332
|
+
try {
|
|
333
|
+
fs.fchownSync(fd, uid, gid);
|
|
334
|
+
fs.writeFileSync(fd, `${JSON.stringify(record, null, 2)}\n`);
|
|
335
|
+
fs.fsyncSync(fd);
|
|
336
|
+
} finally {
|
|
337
|
+
fs.closeSync(fd);
|
|
338
|
+
}
|
|
339
|
+
syncDirectory(path.dirname(file));
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* @param {Readonly<{source?: string, archive?: string, record?: string}>} values
|
|
343
|
+
* @param {number} uid
|
|
344
|
+
*/
|
|
345
|
+
function prepare(values, uid) {
|
|
346
|
+
const source = absolute(values.source);
|
|
347
|
+
const archive = absolute(values.archive);
|
|
348
|
+
const file = absolute(values.record);
|
|
349
|
+
assert.ok(path.basename(source) === "history-index", "E_SOURCE_NAME");
|
|
350
|
+
outside(archive, source);
|
|
351
|
+
outside(source, archive);
|
|
352
|
+
outside(file, source);
|
|
353
|
+
outside(file, archive);
|
|
354
|
+
const recordParent = directory(path.dirname(file), uid, true);
|
|
355
|
+
if (exists(file)) {
|
|
356
|
+
const record = recordAt(file, uid);
|
|
357
|
+
assert.ok(record.source === source, "E_RECORD_SOURCE");
|
|
358
|
+
assert.ok(record.archive === archive, "E_RECORD_ARCHIVE");
|
|
359
|
+
return inspect(record, uid);
|
|
360
|
+
}
|
|
361
|
+
const sourceParent = directory(path.dirname(source), uid);
|
|
362
|
+
const archiveParent = directory(path.dirname(archive), uid, true);
|
|
363
|
+
const sourceStat = directory(source, uid);
|
|
364
|
+
for (const parent of [archiveParent, recordParent]) {
|
|
365
|
+
assert.ok(
|
|
366
|
+
sourceStat.dev !== parent.dev || sourceStat.ino !== parent.ino,
|
|
367
|
+
"E_OVERLAPPING_PATHS",
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
assert.ok(sourceStat.dev === archiveParent.dev, "E_CROSS_DEVICE");
|
|
371
|
+
assert.ok(!exists(archive), "E_UNRECORDED_CONTAINER");
|
|
372
|
+
const members = inventory(source, uid);
|
|
373
|
+
closed(source);
|
|
374
|
+
fs.mkdirSync(archive, { mode: 0o700 });
|
|
375
|
+
fs.chownSync(archive, uid, Number(sourceStat.gid));
|
|
376
|
+
/** @type {ArchiveRecord} */
|
|
377
|
+
const record = {
|
|
378
|
+
version: 1,
|
|
379
|
+
ownerUid: uid,
|
|
380
|
+
source,
|
|
381
|
+
archive,
|
|
382
|
+
sourceParent: identity(sourceParent),
|
|
383
|
+
archiveParent: identity(archiveParent),
|
|
384
|
+
container: identity(directory(archive, uid, true)),
|
|
385
|
+
directory: identity(sourceStat),
|
|
386
|
+
members,
|
|
387
|
+
};
|
|
388
|
+
saveRecord(file, record, uid, Number(sourceStat.gid));
|
|
389
|
+
syncDirectory(path.dirname(archive));
|
|
390
|
+
return inspect(record, uid);
|
|
391
|
+
}
|
|
392
|
+
function main() {
|
|
393
|
+
const { values, positionals } = parseArgs({
|
|
394
|
+
options: {
|
|
395
|
+
source: { type: "string" },
|
|
396
|
+
archive: { type: "string" },
|
|
397
|
+
record: { type: "string" },
|
|
398
|
+
"owner-uid": { type: "string" },
|
|
399
|
+
quiesced: { type: "boolean" },
|
|
400
|
+
help: { type: "boolean", short: "h" },
|
|
401
|
+
},
|
|
402
|
+
allowPositionals: true,
|
|
403
|
+
});
|
|
404
|
+
if (values.help === true) {
|
|
405
|
+
console.log(
|
|
406
|
+
"Usage: node scripts/archive-retired-history.mjs prepare --source /canonical/agent/history-index --archive /private/archives/unique-container --record /private/records/unique.json --owner-uid UID --quiesced\n node scripts/archive-retired-history.mjs inspect --record /private/records/unique.json --owner-uid UID\n node scripts/archive-retired-history.mjs archive --record /private/records/unique.json --owner-uid UID --quiesced\n\nOffline-only opt-in maintenance on Linux/macOS. Run with administrative lsof visibility. --quiesced acknowledges owner-approved stopped/disabled launchers, services, readers and writers; it is NOT an exclusivity check. Read docs/retired-history-maintenance.md first. No startup/install integration, deletion, SQLite open/recovery, or cross-filesystem copy. An interrupted or ambiguous attempt retains its record and both paths for inspection. Archiving reclaims no file allocations.",
|
|
407
|
+
);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
assert.ok(positionals.length === 1, "E_ACTION");
|
|
411
|
+
const action = positionals[0];
|
|
412
|
+
assert.ok(["prepare", "inspect", "archive"].includes(action), "E_ACTION");
|
|
413
|
+
assert.ok(["linux", "darwin"].includes(process.platform), "E_PLATFORM");
|
|
414
|
+
assert.ok(process.geteuid !== undefined, "E_PLATFORM");
|
|
415
|
+
assert.ok(process.geteuid() === 0, "E_PRIVILEGE");
|
|
416
|
+
if (process.platform === "linux") {
|
|
417
|
+
const capability = /^CapEff:\s*([a-f0-9]+)$/im.exec(
|
|
418
|
+
fs.readFileSync("/proc/self/status", "utf8"),
|
|
419
|
+
);
|
|
420
|
+
assert.ok(capability !== null, "E_PROCESS_VISIBILITY");
|
|
421
|
+
assert.ok((BigInt(`0x${capability[1]}`) & (1n << 19n)) !== 0n, "E_PROCESS_VISIBILITY");
|
|
422
|
+
}
|
|
423
|
+
assert.match(values["owner-uid"] ?? "", /^(?:0|[1-9][0-9]*)$/, "E_OWNER_UID");
|
|
424
|
+
const uid = Number(values["owner-uid"]);
|
|
425
|
+
assert.ok(Number.isSafeInteger(uid) && uid <= 4_294_967_294, "E_OWNER_UID");
|
|
426
|
+
if (action !== "inspect") {
|
|
427
|
+
assert.ok(values.quiesced === true, "E_QUIESCENCE_ACK");
|
|
428
|
+
}
|
|
429
|
+
const file = absolute(values.record);
|
|
430
|
+
if (action === "prepare") {
|
|
431
|
+
console.log(
|
|
432
|
+
`${prepare(values, uid)}: inventory recorded; nothing deleted and no file allocations reclaimed.`,
|
|
433
|
+
);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
assert.ok((values.source ?? values.archive) === undefined, "E_RECORD_ONLY");
|
|
437
|
+
const record = recordAt(file, uid);
|
|
438
|
+
let state = inspect(record, uid);
|
|
439
|
+
if (action === "archive" && state === "prepared") {
|
|
440
|
+
assert.ok(!exists(path.join(record.archive, "history-index")), "E_DESTINATION_APPEARED");
|
|
441
|
+
fs.renameSync(record.source, path.join(record.archive, "history-index"));
|
|
442
|
+
syncDirectory(path.dirname(record.source));
|
|
443
|
+
syncDirectory(record.archive);
|
|
444
|
+
state = inspect(record, uid);
|
|
445
|
+
}
|
|
446
|
+
console.log(
|
|
447
|
+
`${state}: verified physical state; nothing deleted and no file allocations reclaimed.`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
main();
|
|
452
|
+
} catch (error) {
|
|
453
|
+
let code = "E_INSPECTION_FAILED";
|
|
454
|
+
if (error instanceof assert.AssertionError && !error.generatedMessage) {
|
|
455
|
+
const firstLine = error.message.split("\n")[0];
|
|
456
|
+
if (/^E_[A-Z_]+$/.test(firstLine)) {
|
|
457
|
+
code = firstLine;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
console.error(
|
|
461
|
+
`[retired-history] ${code}\nStop. Preserve the external record and both locations; do not delete, merge or retry blindly. See the maintenance guide. Private paths, contents and inspector output are not printed.`,
|
|
462
|
+
);
|
|
463
|
+
process.exitCode = 1;
|
|
464
|
+
}
|
package/scripts/build.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Purpose: Produce the compiled runtime files that the Pi extension manifest loads.
|
|
4
|
-
* Responsibilities: Run TypeScript emit into a staging directory,
|
|
5
|
-
* into
|
|
4
|
+
* Responsibilities: Run TypeScript emit into a staging directory, move the last-good dist/ aside,
|
|
5
|
+
* and rename the complete staging tree into place. A failed emit never touches dist/; a failed
|
|
6
|
+
* publication renames the last-good tree back. Replacement is two renames, not an atomic exchange:
|
|
7
|
+
* a concurrent reader can briefly find no dist/, but never a partial tree.
|
|
6
8
|
* Usage: `npm run build`; also invoked by scripts/prepare.mjs during install lifecycles, which may pass
|
|
7
|
-
* the path of a throwaway compiler's `bin/tsc` as the first argument.
|
|
8
|
-
* Invariants/Assumptions:
|
|
9
|
+
* the path of a throwaway compiler's `bin/tsc` as the first argument. Android uses native tsgo.
|
|
10
|
+
* Invariants/Assumptions: deleting `dist/` is safe generated output.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import { execFile as execFileCallback } from "node:child_process";
|
|
@@ -16,17 +18,16 @@ import { join } from "node:path";
|
|
|
16
18
|
import process from "node:process";
|
|
17
19
|
import { setTimeout as delay } from "node:timers/promises";
|
|
18
20
|
import { promisify } from "node:util";
|
|
21
|
+
import { compilerInvocation } from "./typescript.mjs";
|
|
19
22
|
|
|
20
23
|
const execFile = promisify(execFileCallback);
|
|
21
24
|
const RM_OPTIONS = { force: true, maxRetries: 5, recursive: true, retryDelay: 100 };
|
|
22
25
|
const RENAME_RETRY_LIMIT = 50;
|
|
23
26
|
const RENAME_RETRY_MS = 50;
|
|
24
|
-
// Use the current Node binary without a shell, including install paths with spaces.
|
|
25
|
-
const tscPath = process.argv[2] ?? join(process.cwd(), "node_modules", "typescript", "bin", "tsc");
|
|
26
27
|
|
|
27
28
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
28
29
|
console.log(
|
|
29
|
-
"Usage: node scripts/build.mjs [compiler-path]\n\nCompile into PID-owned staging, stamp the package version/content hash, then publish dist.\nA failed compiler preserves existing dist. Concurrent publishers retain
|
|
30
|
+
"Usage: node scripts/build.mjs [compiler-path]\n\nCompile into PID-owned staging, stamp the package version/content hash, then publish dist.\nA failed compiler preserves existing dist; a failed publication restores it. Concurrent publishers retain a complete winning tree.\nExample: npm run build\nExit codes: 0 published or concurrent winner retained; 1 compile/publish failed.",
|
|
30
31
|
);
|
|
31
32
|
process.exit(0);
|
|
32
33
|
}
|
|
@@ -62,7 +63,7 @@ async function reapStrandedStaging(cwd) {
|
|
|
62
63
|
// Signal 0 proves only that the owner was gone at probe time. Pid reuse
|
|
63
64
|
// between this probe and removal is inherent to pid-based reaping.
|
|
64
65
|
for (const entry of await readdir(cwd, { withFileTypes: true })) {
|
|
65
|
-
const match = /^dist\.staging\.(\d+)
|
|
66
|
+
const match = /^dist\.staging\.(\d+)(?:\.previous)?$/.exec(entry.name);
|
|
66
67
|
if (!match || !entry.isDirectory()) {
|
|
67
68
|
continue;
|
|
68
69
|
}
|
|
@@ -76,19 +77,24 @@ async function reapStrandedStaging(cwd) {
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
async function
|
|
80
|
+
async function compileProject(cwd, stagingDir, compiler, project) {
|
|
81
|
+
const { stderr, stdout } = await execFile(
|
|
82
|
+
compiler.command,
|
|
83
|
+
[...compiler.args, "-p", project, "--outDir", stagingDir],
|
|
84
|
+
{ cwd, maxBuffer: 10 * 1024 * 1024 },
|
|
85
|
+
);
|
|
86
|
+
if (stdout) {
|
|
87
|
+
process.stdout.write(stdout);
|
|
88
|
+
}
|
|
89
|
+
if (stderr) {
|
|
90
|
+
process.stderr.write(stderr);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function compileToStaging(cwd, stagingDir, compiler) {
|
|
80
95
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
[tscPath, "-p", "tsconfig.build.json", "--outDir", stagingDir],
|
|
84
|
-
{ cwd, maxBuffer: 10 * 1024 * 1024 },
|
|
85
|
-
);
|
|
86
|
-
if (stdout) {
|
|
87
|
-
process.stdout.write(stdout);
|
|
88
|
-
}
|
|
89
|
-
if (stderr) {
|
|
90
|
-
process.stderr.write(stderr);
|
|
91
|
-
}
|
|
96
|
+
await compileProject(cwd, stagingDir, compiler, "tsconfig.build.json");
|
|
97
|
+
await compileProject(cwd, stagingDir, compiler, "tsconfig.client.json");
|
|
92
98
|
await stampBuild(cwd, stagingDir);
|
|
93
99
|
} catch (error) {
|
|
94
100
|
if (error?.stdout) {
|
|
@@ -142,17 +148,32 @@ async function tryPublish(stagingDir, distDir, retry) {
|
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
async function publishStaging(stagingDir, distDir) {
|
|
151
|
+
const previousDir = `${stagingDir}.previous`;
|
|
145
152
|
try {
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
// Retire dist exactly once, outside the retry loop, so retries never retire a concurrent winner.
|
|
154
|
+
await rename(distDir, previousDir).catch((error) => {
|
|
155
|
+
if (error?.code !== "ENOENT") {
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
149
159
|
for (let retry = 0; ; retry++) {
|
|
150
160
|
// Each retry must observe publication before another rename can race it.
|
|
151
161
|
// oxlint-disable-next-line no-await-in-loop
|
|
152
162
|
if (await tryPublish(stagingDir, distDir, retry)) {
|
|
153
|
-
|
|
163
|
+
break;
|
|
154
164
|
}
|
|
155
165
|
}
|
|
166
|
+
await discardStaging(previousDir);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
// ENOENT: nothing was retired. Otherwise a concurrent winner may already own dist.
|
|
169
|
+
await rename(previousDir, distDir).catch((restoreError) => {
|
|
170
|
+
if (restoreError?.code !== "ENOENT") {
|
|
171
|
+
console.warn(
|
|
172
|
+
`could not restore ${previousDir} to dist: ${restoreError?.message ?? restoreError}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
throw error;
|
|
156
177
|
} finally {
|
|
157
178
|
// Safe after success too: rename moved stagingDir, so force makes this a no-op.
|
|
158
179
|
await discardStaging(stagingDir);
|
|
@@ -161,14 +182,13 @@ async function publishStaging(stagingDir, distDir) {
|
|
|
161
182
|
|
|
162
183
|
async function main() {
|
|
163
184
|
const cwd = process.cwd();
|
|
164
|
-
|
|
165
|
-
throw new Error(`typescript is not installed at ${tscPath}; run npm install first.`);
|
|
166
|
-
}
|
|
185
|
+
const compiler = compilerInvocation(process.argv[2]);
|
|
167
186
|
await reapStrandedStaging(cwd);
|
|
168
187
|
// Pid-scoped staging isolates concurrent emits; only a complete tree publishes.
|
|
169
188
|
const stagingDir = join(cwd, `dist.staging.${process.pid}`);
|
|
170
189
|
await rm(stagingDir, RM_OPTIONS);
|
|
171
|
-
await
|
|
190
|
+
await rm(`${stagingDir}.previous`, RM_OPTIONS);
|
|
191
|
+
await compileToStaging(cwd, stagingDir, compiler);
|
|
172
192
|
await publishStaging(stagingDir, join(cwd, "dist"));
|
|
173
193
|
}
|
|
174
194
|
|
package/scripts/measure-size.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { extname, join } from "node:path";
|
|
|
4
4
|
|
|
5
5
|
const roots = ["src", "scripts", "generated"];
|
|
6
6
|
const extensions = new Set([".ts", ".js", ".mjs", ".cjs", ".json", ".sh"]);
|
|
7
|
-
const ceilings = { sourceLines:
|
|
7
|
+
const ceilings = { sourceLines: 19000, productionLines: 22500, productionBytes: 1048576 };
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* @param {string} directory
|