@withtruing/companion 0.13.2 → 0.14.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/README.md +25 -26
- package/dist/anthropic-output-schema.js +41 -0
- package/dist/api-key-caller.js +9 -2
- package/dist/claude-cli-caller.js +107 -8
- package/dist/cli.js +837 -207
- package/dist/codex-binary.js +96 -0
- package/dist/codex-cli-caller.js +90 -16
- package/dist/config.js +177 -2
- package/dist/git-range.js +88 -46
- package/dist/http-client.js +95 -10
- package/dist/investigation-reader.js +12 -6
- package/dist/managed-repo.js +132 -0
- package/dist/observed-sources.js +132 -6
- package/dist/reader-result-contract.js +296 -2
- package/dist/repository-provider.js +608 -0
- package/dist/schema-entities.js +18 -18
- package/dist/service.js +7 -2
- package/dist/surface-mapper.js +53 -11
- package/dist/text-model.js +91 -8
- package/dist/whole-read/archive.js +196 -0
- package/dist/whole-read/checks.js +297 -0
- package/dist/whole-read/claims.js +196 -0
- package/dist/whole-read/coverage-ledger.js +51 -0
- package/dist/whole-read/decode.js +234 -0
- package/dist/whole-read/drafting.js +502 -0
- package/dist/whole-read/generated-rules.js +68 -0
- package/dist/whole-read/go-proof.js +255 -0
- package/dist/whole-read/grouping-prompt.js +19 -0
- package/dist/whole-read/grouping.js +247 -0
- package/dist/whole-read/identity.js +103 -0
- package/dist/whole-read/import-graph.js +293 -0
- package/dist/whole-read/inventory-collector.js +55 -0
- package/dist/whole-read/inventory-rules.js +141 -0
- package/dist/whole-read/lockfiles.js +947 -0
- package/dist/whole-read/notes.js +216 -0
- package/dist/whole-read/npm-proof.js +272 -0
- package/dist/whole-read/owner-decision-detectors.js +270 -0
- package/dist/whole-read/packing.js +612 -0
- package/dist/whole-read/pages.js +429 -0
- package/dist/whole-read/phase-policy.js +54 -0
- package/dist/whole-read/prd-build.js +279 -0
- package/dist/whole-read/prd-fidelity.js +14 -0
- package/dist/whole-read/python-proof.js +489 -0
- package/dist/whole-read/read-prompt.js +67 -0
- package/dist/whole-read/redaction.js +411 -0
- package/dist/whole-read/relationship-mapping.js +98 -0
- package/dist/whole-read/sanitize.js +266 -0
- package/dist/whole-read/secret-rules.js +26 -0
- package/dist/whole-read/structure.js +300 -0
- package/dist/whole-read/types.js +2 -0
- package/dist/whole-read-claims.js +12 -0
- package/dist/whole-read-git.js +340 -0
- package/dist/whole-read-pool.js +80 -0
- package/dist/whole-read-runner.js +945 -0
- package/dist/whole-read-shared-source.js +194 -0
- package/dist/whole-read-temp.js +479 -0
- package/package.json +8 -2
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { acquireAttemptSource, } from './whole-read-temp.js';
|
|
2
|
+
const DEFAULT_IDLE_MS = 5_000;
|
|
3
|
+
const LEASE_EXTENSION_MS = 24 * 60 * 60_000;
|
|
4
|
+
function exactKey(value) {
|
|
5
|
+
return `${value.assignmentId}\0${value.assignmentRevision}\0${value.snapshotSha}`;
|
|
6
|
+
}
|
|
7
|
+
export function createWholeReadSharedSourceManager(dependencies = {}) {
|
|
8
|
+
const acquireLease = dependencies.acquireLease ?? acquireAttemptSource;
|
|
9
|
+
const idleMs = dependencies.idleMs ?? DEFAULT_IDLE_MS;
|
|
10
|
+
const clock = dependencies.now ?? (() => new Date());
|
|
11
|
+
const schedule = dependencies.setTimeout ?? setTimeout;
|
|
12
|
+
const cancel = dependencies.clearTimeout ?? clearTimeout;
|
|
13
|
+
const releaseSource = dependencies.releaseSource ?? (async (source) => {
|
|
14
|
+
const releasable = source;
|
|
15
|
+
await releasable.release?.();
|
|
16
|
+
});
|
|
17
|
+
const states = new Map();
|
|
18
|
+
const cleanupJobs = new Set();
|
|
19
|
+
let stopping = false;
|
|
20
|
+
const reportCleanup = async (record) => {
|
|
21
|
+
if (record.state === 'cleanup_pending')
|
|
22
|
+
await dependencies.onCleanupPending?.(record);
|
|
23
|
+
};
|
|
24
|
+
const cleanup = (state) => {
|
|
25
|
+
if (state.cleanup)
|
|
26
|
+
return state.cleanup;
|
|
27
|
+
const job = (async () => {
|
|
28
|
+
if (state.idleTimer) {
|
|
29
|
+
cancel(state.idleTimer);
|
|
30
|
+
state.idleTimer = undefined;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
await state.acquisition;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
await releaseSource(state.source);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
const record = await state.lease.release();
|
|
43
|
+
await reportCleanup(record);
|
|
44
|
+
states.delete(exactKey(state.key));
|
|
45
|
+
}
|
|
46
|
+
})();
|
|
47
|
+
state.cleanup = job;
|
|
48
|
+
cleanupJobs.add(job);
|
|
49
|
+
void job.finally(() => cleanupJobs.delete(job));
|
|
50
|
+
return job;
|
|
51
|
+
};
|
|
52
|
+
const scheduleIdle = (state) => {
|
|
53
|
+
if (state.refs !== 0 || state.cleanup || state.idleTimer)
|
|
54
|
+
return;
|
|
55
|
+
if (state.retired || stopping) {
|
|
56
|
+
void cleanup(state);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
state.idleTimer = schedule(() => {
|
|
60
|
+
state.idleTimer = undefined;
|
|
61
|
+
if (state.refs === 0)
|
|
62
|
+
void cleanup(state);
|
|
63
|
+
}, idleMs);
|
|
64
|
+
if (typeof state.idleTimer === 'object' && state.idleTimer !== null && 'unref' in state.idleTimer) {
|
|
65
|
+
state.idleTimer.unref();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const retire = async (state) => {
|
|
69
|
+
state.retired = true;
|
|
70
|
+
if (state.acquiring)
|
|
71
|
+
state.controller.abort();
|
|
72
|
+
if (state.refs === 0)
|
|
73
|
+
await cleanup(state);
|
|
74
|
+
};
|
|
75
|
+
const waitForAcquisition = async (state, signal) => {
|
|
76
|
+
if (!signal)
|
|
77
|
+
return state.acquisition;
|
|
78
|
+
if (signal.aborted)
|
|
79
|
+
throw signal.reason ?? new Error('The operation was aborted');
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const removeAbortListener = () => signal.removeEventListener('abort', onAbort);
|
|
82
|
+
const onAbort = () => {
|
|
83
|
+
removeAbortListener();
|
|
84
|
+
reject(signal.reason ?? new Error('The operation was aborted'));
|
|
85
|
+
};
|
|
86
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
87
|
+
void state.acquisition.then((source) => { removeAbortListener(); resolve(source); }, (error) => { removeAbortListener(); reject(error); });
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
async acquire(key, acquire, signal) {
|
|
92
|
+
if (stopping)
|
|
93
|
+
throw new Error('source_lease_lost');
|
|
94
|
+
const identity = exactKey(key);
|
|
95
|
+
for (const state of states.values()) {
|
|
96
|
+
if (state.key.assignmentId === key.assignmentId && exactKey(state.key) !== identity)
|
|
97
|
+
await retire(state);
|
|
98
|
+
}
|
|
99
|
+
let state = states.get(identity);
|
|
100
|
+
if (!state) {
|
|
101
|
+
const created = {
|
|
102
|
+
key: { ...key }, refs: 0, retired: false,
|
|
103
|
+
controller: new AbortController(), acquiring: true,
|
|
104
|
+
acquisition: Promise.resolve(undefined),
|
|
105
|
+
};
|
|
106
|
+
state = created;
|
|
107
|
+
states.set(identity, state);
|
|
108
|
+
state.acquisition = (async () => {
|
|
109
|
+
let lease;
|
|
110
|
+
try {
|
|
111
|
+
lease = await acquireLease({
|
|
112
|
+
workspaceId: key.workspaceId,
|
|
113
|
+
buildId: key.buildId,
|
|
114
|
+
attemptId: `shared-${key.assignmentId}-${key.assignmentRevision}-${key.snapshotSha}`,
|
|
115
|
+
hostId: key.hostId,
|
|
116
|
+
leaseExpiresAt: new Date(clock().getTime() + LEASE_EXTENSION_MS).toISOString(),
|
|
117
|
+
});
|
|
118
|
+
state.lease = lease;
|
|
119
|
+
const source = await acquire(lease, state.controller.signal);
|
|
120
|
+
state.source = source;
|
|
121
|
+
return source;
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
try {
|
|
125
|
+
if (lease) {
|
|
126
|
+
const record = await lease.release();
|
|
127
|
+
await reportCleanup(record);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
states.delete(identity);
|
|
132
|
+
}
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
state.acquiring = false;
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
}
|
|
140
|
+
if (state.idleTimer) {
|
|
141
|
+
cancel(state.idleTimer);
|
|
142
|
+
state.idleTimer = undefined;
|
|
143
|
+
}
|
|
144
|
+
state.refs++;
|
|
145
|
+
let source;
|
|
146
|
+
try {
|
|
147
|
+
source = await waitForAcquisition(state, signal);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
state.refs--;
|
|
151
|
+
scheduleIdle(state);
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
const renewed = new Date(clock().getTime() + LEASE_EXTENSION_MS).toISOString();
|
|
155
|
+
try {
|
|
156
|
+
if (Date.parse(renewed) > Date.parse(state.lease.owner.leaseExpiresAt)) {
|
|
157
|
+
const renewal = state.renewal ?? state.lease.renew(renewed);
|
|
158
|
+
state.renewal = renewal;
|
|
159
|
+
try {
|
|
160
|
+
await renewal;
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
if (state.renewal === renewal)
|
|
164
|
+
state.renewal = undefined;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
state.refs--;
|
|
170
|
+
scheduleIdle(state);
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
let released = false;
|
|
174
|
+
return { source, async release() {
|
|
175
|
+
if (released)
|
|
176
|
+
return;
|
|
177
|
+
released = true;
|
|
178
|
+
state.refs--;
|
|
179
|
+
scheduleIdle(state);
|
|
180
|
+
} };
|
|
181
|
+
},
|
|
182
|
+
async reconcile(active) {
|
|
183
|
+
const identities = new Set(active.map(exactKey));
|
|
184
|
+
await Promise.all([...states.values()]
|
|
185
|
+
.filter((state) => !identities.has(exactKey(state.key))).map(retire));
|
|
186
|
+
},
|
|
187
|
+
async settled() { await Promise.allSettled([...cleanupJobs]); },
|
|
188
|
+
async shutdown() {
|
|
189
|
+
stopping = true;
|
|
190
|
+
await Promise.all([...states.values()].map(retire));
|
|
191
|
+
await Promise.allSettled([...cleanupJobs]);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { sanitizeBuildMetadata } from './whole-read/sanitize.js';
|
|
6
|
+
const SWEEP_INTERVAL_MS = 10 * 60_000;
|
|
7
|
+
const MAX_DELETE_ATTEMPTS = 5;
|
|
8
|
+
const MARKER_FILE = '.whole-read-owner.json';
|
|
9
|
+
const RECORDS_DIR = 'records';
|
|
10
|
+
const LOCATION_ID = /^[A-Za-z0-9_-]+$/;
|
|
11
|
+
export class AttemptSourceError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code) {
|
|
14
|
+
super(code);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.name = 'AttemptSourceError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class AttemptSourceCleanupError extends Error {
|
|
20
|
+
cleanup;
|
|
21
|
+
code = 'cleanup_pending';
|
|
22
|
+
constructor(cleanup) {
|
|
23
|
+
super('cleanup_pending');
|
|
24
|
+
this.cleanup = cleanup;
|
|
25
|
+
this.name = 'AttemptSourceCleanupError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
class CleanupSafetyError extends Error {
|
|
29
|
+
code;
|
|
30
|
+
constructor(code) {
|
|
31
|
+
super(code);
|
|
32
|
+
this.code = code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const nonBlank = (value) => typeof value === 'string' && value.trim().length > 0;
|
|
36
|
+
function normalizedTime(value) {
|
|
37
|
+
const milliseconds = Date.parse(value);
|
|
38
|
+
if (!Number.isFinite(milliseconds))
|
|
39
|
+
throw new AttemptSourceError('source_owner_invalid');
|
|
40
|
+
return new Date(milliseconds).toISOString();
|
|
41
|
+
}
|
|
42
|
+
function normalizeOwner(owner, now) {
|
|
43
|
+
if (![owner.workspaceId, owner.buildId, owner.attemptId, owner.hostId].every(nonBlank)) {
|
|
44
|
+
throw new AttemptSourceError('source_owner_invalid');
|
|
45
|
+
}
|
|
46
|
+
const leaseExpiresAt = normalizedTime(owner.leaseExpiresAt);
|
|
47
|
+
if (leaseExpiresAt <= now)
|
|
48
|
+
throw new AttemptSourceError('source_owner_invalid');
|
|
49
|
+
return { ...owner, leaseExpiresAt };
|
|
50
|
+
}
|
|
51
|
+
function markerFor(owner, locationId) {
|
|
52
|
+
return { ...owner, locationId };
|
|
53
|
+
}
|
|
54
|
+
function parseMarker(value) {
|
|
55
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
56
|
+
return null;
|
|
57
|
+
const candidate = value;
|
|
58
|
+
const keys = Object.keys(candidate).sort();
|
|
59
|
+
const expected = ['attemptId', 'buildId', 'hostId', 'leaseExpiresAt', 'locationId', 'workspaceId'];
|
|
60
|
+
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index]))
|
|
61
|
+
return null;
|
|
62
|
+
if (![candidate.workspaceId, candidate.buildId, candidate.attemptId, candidate.hostId].every(nonBlank)
|
|
63
|
+
|| typeof candidate.locationId !== 'string' || !LOCATION_ID.test(candidate.locationId)
|
|
64
|
+
|| typeof candidate.leaseExpiresAt !== 'string'
|
|
65
|
+
|| !Number.isFinite(Date.parse(candidate.leaseExpiresAt)))
|
|
66
|
+
return null;
|
|
67
|
+
return candidate;
|
|
68
|
+
}
|
|
69
|
+
function parseRecord(value) {
|
|
70
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
71
|
+
return null;
|
|
72
|
+
const candidate = value;
|
|
73
|
+
const keys = Object.keys(candidate).sort();
|
|
74
|
+
const expected = [
|
|
75
|
+
'attemptId', 'buildId', 'cleanupAttempts', 'deletedAt', 'errorCode', 'hostId',
|
|
76
|
+
'leaseExpiresAt', 'locationId', 'path', 'state', 'workspaceId',
|
|
77
|
+
];
|
|
78
|
+
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index]))
|
|
79
|
+
return null;
|
|
80
|
+
const marker = parseMarker(Object.fromEntries(['workspaceId', 'buildId', 'attemptId', 'hostId', 'leaseExpiresAt', 'locationId']
|
|
81
|
+
.map((key) => [key, candidate[key]])));
|
|
82
|
+
if (!marker || typeof candidate.path !== 'string'
|
|
83
|
+
|| !['live', 'deleted', 'cleanup_pending'].includes(String(candidate.state))
|
|
84
|
+
|| !Number.isSafeInteger(candidate.cleanupAttempts) || candidate.cleanupAttempts < 0
|
|
85
|
+
|| !(candidate.errorCode === null || [
|
|
86
|
+
'cleanup_delete_failed',
|
|
87
|
+
'cleanup_reader_cancel_failed',
|
|
88
|
+
'cleanup_path_outside_root',
|
|
89
|
+
'cleanup_unsafe_link',
|
|
90
|
+
'cleanup_marker_invalid',
|
|
91
|
+
].includes(String(candidate.errorCode)))
|
|
92
|
+
|| !(candidate.deletedAt === null || (typeof candidate.deletedAt === 'string'
|
|
93
|
+
&& Number.isFinite(Date.parse(candidate.deletedAt)))))
|
|
94
|
+
return null;
|
|
95
|
+
return candidate;
|
|
96
|
+
}
|
|
97
|
+
function cleanupRecord(record) {
|
|
98
|
+
if (record.state === 'live')
|
|
99
|
+
throw new Error('live cleanup record');
|
|
100
|
+
return {
|
|
101
|
+
workspaceId: record.workspaceId,
|
|
102
|
+
buildId: record.buildId,
|
|
103
|
+
attemptId: record.attemptId,
|
|
104
|
+
hostId: record.hostId,
|
|
105
|
+
leaseExpiresAt: record.leaseExpiresAt,
|
|
106
|
+
locationId: record.locationId,
|
|
107
|
+
path: record.path,
|
|
108
|
+
state: record.state,
|
|
109
|
+
cleanupAttempts: record.cleanupAttempts,
|
|
110
|
+
errorCode: record.errorCode,
|
|
111
|
+
deletedAt: record.deletedAt,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
async function missing(path) {
|
|
115
|
+
try {
|
|
116
|
+
await lstat(path);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
return error.code === 'ENOENT';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function assertTreeHasNoLinks(path) {
|
|
124
|
+
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
125
|
+
const child = join(path, entry.name);
|
|
126
|
+
const stats = await lstat(child);
|
|
127
|
+
if (stats.isSymbolicLink())
|
|
128
|
+
throw new CleanupSafetyError('cleanup_unsafe_link');
|
|
129
|
+
if (stats.isDirectory())
|
|
130
|
+
await assertTreeHasNoLinks(child);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function validateDeleteTarget(root, record) {
|
|
134
|
+
const absoluteRoot = resolve(root);
|
|
135
|
+
const absoluteTarget = resolve(record.path);
|
|
136
|
+
const fromRoot = relative(absoluteRoot, absoluteTarget);
|
|
137
|
+
if (dirname(absoluteTarget) !== absoluteRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) {
|
|
138
|
+
throw new CleanupSafetyError('cleanup_path_outside_root');
|
|
139
|
+
}
|
|
140
|
+
if (await missing(absoluteTarget))
|
|
141
|
+
return;
|
|
142
|
+
const [rootStats, targetStats] = await Promise.all([lstat(absoluteRoot), lstat(absoluteTarget)]);
|
|
143
|
+
if (rootStats.isSymbolicLink() || targetStats.isSymbolicLink() || !targetStats.isDirectory()) {
|
|
144
|
+
throw new CleanupSafetyError('cleanup_unsafe_link');
|
|
145
|
+
}
|
|
146
|
+
const [canonicalRoot, canonicalTarget] = await Promise.all([realpath(absoluteRoot), realpath(absoluteTarget)]);
|
|
147
|
+
if (dirname(canonicalTarget) !== canonicalRoot) {
|
|
148
|
+
throw new CleanupSafetyError('cleanup_path_outside_root');
|
|
149
|
+
}
|
|
150
|
+
let marker = null;
|
|
151
|
+
try {
|
|
152
|
+
marker = parseMarker(JSON.parse(await readFile(join(absoluteTarget, MARKER_FILE), 'utf8')));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// The fixed code below is all that may leave this host.
|
|
156
|
+
}
|
|
157
|
+
if (!marker || marker.workspaceId !== record.workspaceId || marker.buildId !== record.buildId
|
|
158
|
+
|| marker.attemptId !== record.attemptId || marker.hostId !== record.hostId
|
|
159
|
+
|| marker.locationId !== record.locationId
|
|
160
|
+
|| Date.parse(marker.leaseExpiresAt) > Date.parse(record.leaseExpiresAt)) {
|
|
161
|
+
throw new CleanupSafetyError('cleanup_marker_invalid');
|
|
162
|
+
}
|
|
163
|
+
await assertTreeHasNoLinks(absoluteTarget);
|
|
164
|
+
}
|
|
165
|
+
export function createCompanionAttemptSourceManager(dependencies = {}) {
|
|
166
|
+
const root = resolve(dependencies.root ?? join(tmpdir(), 'fynch-product-console-companion', 'whole-read'));
|
|
167
|
+
const recordsRoot = join(root, RECORDS_DIR);
|
|
168
|
+
const clock = dependencies.now ?? (() => new Date());
|
|
169
|
+
const randomLocationId = dependencies.randomLocationId ?? randomUUID;
|
|
170
|
+
const wait = dependencies.wait
|
|
171
|
+
?? ((delayMs) => new Promise((resolveWait) => setTimeout(resolveWait, delayMs)));
|
|
172
|
+
const retryDelaysMs = dependencies.retryDelaysMs ?? [25, 50, 100, 200];
|
|
173
|
+
const removeTree = dependencies.removeTree ?? ((path) => rm(path, { recursive: true, force: false }));
|
|
174
|
+
const reportCleanup = dependencies.reportCleanup ?? (async () => false);
|
|
175
|
+
const scheduleInterval = dependencies.setInterval ?? setInterval;
|
|
176
|
+
const cancelInterval = dependencies.clearInterval ?? clearInterval;
|
|
177
|
+
const buildsEnabled = dependencies.buildsEnabled ?? (() => process.env.WHOLE_READ_BUILDS === '1');
|
|
178
|
+
const readers = new Map();
|
|
179
|
+
const activeLocations = new Set();
|
|
180
|
+
const cleanupJobs = new Map();
|
|
181
|
+
let timer = null;
|
|
182
|
+
let starting = null;
|
|
183
|
+
let sweepRunning = false;
|
|
184
|
+
let lifecycleVersion = 0;
|
|
185
|
+
const recordPath = (locationId) => join(recordsRoot, `${locationId}.json`);
|
|
186
|
+
const ensureRoots = async () => {
|
|
187
|
+
await mkdir(root, { recursive: true });
|
|
188
|
+
const rootStats = await lstat(root);
|
|
189
|
+
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
|
|
190
|
+
throw new CleanupSafetyError('cleanup_unsafe_link');
|
|
191
|
+
}
|
|
192
|
+
await mkdir(recordsRoot, { recursive: true });
|
|
193
|
+
const recordsStats = await lstat(recordsRoot);
|
|
194
|
+
const [canonicalRoot, canonicalRecords] = await Promise.all([realpath(root), realpath(recordsRoot)]);
|
|
195
|
+
if (recordsStats.isSymbolicLink() || !recordsStats.isDirectory()
|
|
196
|
+
|| dirname(canonicalRecords) !== canonicalRoot) {
|
|
197
|
+
throw new CleanupSafetyError('cleanup_unsafe_link');
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const saveRecord = async (record) => {
|
|
201
|
+
await ensureRoots();
|
|
202
|
+
const temporary = join(recordsRoot, `.${record.locationId}.${randomUUID()}.tmp`);
|
|
203
|
+
try {
|
|
204
|
+
await writeFile(temporary, JSON.stringify(record), { encoding: 'utf8', flag: 'wx' });
|
|
205
|
+
await rename(temporary, recordPath(record.locationId));
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const notify = async (record) => {
|
|
212
|
+
let acknowledged = false;
|
|
213
|
+
try {
|
|
214
|
+
const safe = sanitizeBuildMetadata(record, 'owner_scoped_cleanup');
|
|
215
|
+
if (!safe.ok)
|
|
216
|
+
return;
|
|
217
|
+
acknowledged = await reportCleanup(safe.value);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Cleanup must remain local-first when the old Console is offline.
|
|
221
|
+
}
|
|
222
|
+
if (acknowledged && record.state === 'deleted') {
|
|
223
|
+
await rm(recordPath(record.locationId), { force: true }).catch(() => undefined);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const recoverUntrackedSources = async () => {
|
|
227
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
228
|
+
if (!entry.isDirectory() || !entry.name.startsWith('source-'))
|
|
229
|
+
continue;
|
|
230
|
+
const path = join(root, entry.name);
|
|
231
|
+
let marker = null;
|
|
232
|
+
try {
|
|
233
|
+
marker = parseMarker(JSON.parse(await readFile(join(path, MARKER_FILE), 'utf8')));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (!marker || entry.name !== `source-${marker.locationId}`
|
|
239
|
+
|| !(await missing(recordPath(marker.locationId))))
|
|
240
|
+
continue;
|
|
241
|
+
await saveRecord({
|
|
242
|
+
...marker,
|
|
243
|
+
path,
|
|
244
|
+
state: 'live',
|
|
245
|
+
cleanupAttempts: 0,
|
|
246
|
+
errorCode: null,
|
|
247
|
+
deletedAt: null,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const readRecords = async () => {
|
|
252
|
+
await ensureRoots();
|
|
253
|
+
await recoverUntrackedSources();
|
|
254
|
+
const records = [];
|
|
255
|
+
for (const name of (await readdir(recordsRoot)).filter((entry) => entry.endsWith('.json')).sort()) {
|
|
256
|
+
try {
|
|
257
|
+
const parsed = parseRecord(JSON.parse(await readFile(join(recordsRoot, name), 'utf8')));
|
|
258
|
+
if (parsed && name === `${parsed.locationId}.json`)
|
|
259
|
+
records.push(parsed);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// An invalid ledger entry is never enough authority to delete a path.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return records;
|
|
266
|
+
};
|
|
267
|
+
const cancelReaders = async (locationId) => {
|
|
268
|
+
for (const reader of [...(readers.get(locationId) ?? [])])
|
|
269
|
+
await reader.cancel();
|
|
270
|
+
readers.delete(locationId);
|
|
271
|
+
};
|
|
272
|
+
const performCleanup = async (initial, at) => {
|
|
273
|
+
const record = { ...initial, state: 'cleanup_pending', deletedAt: null };
|
|
274
|
+
await saveRecord(record);
|
|
275
|
+
try {
|
|
276
|
+
await cancelReaders(record.locationId);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
record.cleanupAttempts += 1;
|
|
280
|
+
record.errorCode = 'cleanup_reader_cancel_failed';
|
|
281
|
+
await saveRecord(record);
|
|
282
|
+
const result = cleanupRecord(record);
|
|
283
|
+
await notify(result);
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
for (let attempt = 1; attempt <= MAX_DELETE_ATTEMPTS; attempt += 1) {
|
|
287
|
+
try {
|
|
288
|
+
await validateDeleteTarget(root, record);
|
|
289
|
+
if (!(await missing(record.path)))
|
|
290
|
+
await removeTree(record.path);
|
|
291
|
+
if (!(await missing(record.path)))
|
|
292
|
+
throw new Error('cleanup_delete_failed');
|
|
293
|
+
record.cleanupAttempts += attempt;
|
|
294
|
+
record.state = 'deleted';
|
|
295
|
+
record.errorCode = null;
|
|
296
|
+
record.deletedAt = normalizedTime(at);
|
|
297
|
+
activeLocations.delete(record.locationId);
|
|
298
|
+
await saveRecord(record);
|
|
299
|
+
const result = cleanupRecord(record);
|
|
300
|
+
await notify(result);
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (error instanceof CleanupSafetyError) {
|
|
305
|
+
record.cleanupAttempts += 1;
|
|
306
|
+
record.errorCode = error.code;
|
|
307
|
+
await saveRecord(record);
|
|
308
|
+
const result = cleanupRecord(record);
|
|
309
|
+
await notify(result);
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
if (attempt < MAX_DELETE_ATTEMPTS) {
|
|
313
|
+
await wait(retryDelaysMs[Math.min(attempt - 1, retryDelaysMs.length - 1)] ?? 0);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
record.cleanupAttempts += MAX_DELETE_ATTEMPTS;
|
|
318
|
+
record.errorCode = 'cleanup_delete_failed';
|
|
319
|
+
await saveRecord(record);
|
|
320
|
+
const result = cleanupRecord(record);
|
|
321
|
+
await notify(result);
|
|
322
|
+
return result;
|
|
323
|
+
};
|
|
324
|
+
const cleanupLocation = (record, at) => {
|
|
325
|
+
const active = cleanupJobs.get(record.locationId);
|
|
326
|
+
if (active)
|
|
327
|
+
return active;
|
|
328
|
+
const cleanup = performCleanup(record, at).finally(() => cleanupJobs.delete(record.locationId));
|
|
329
|
+
cleanupJobs.set(record.locationId, cleanup);
|
|
330
|
+
return cleanup;
|
|
331
|
+
};
|
|
332
|
+
const sweepAttemptSources = async (at) => {
|
|
333
|
+
const now = normalizedTime(at);
|
|
334
|
+
const report = { deleted: [], cleanupPending: [], preservedLive: 0 };
|
|
335
|
+
for (const record of await readRecords()) {
|
|
336
|
+
if (record.state === 'deleted') {
|
|
337
|
+
const result = cleanupRecord(record);
|
|
338
|
+
report.deleted.push(result);
|
|
339
|
+
await notify(result);
|
|
340
|
+
}
|
|
341
|
+
else if (record.state === 'live' && activeLocations.has(record.locationId)
|
|
342
|
+
&& Date.parse(record.leaseExpiresAt) > Date.parse(now)) {
|
|
343
|
+
report.preservedLive += 1;
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
const result = await cleanupLocation(record, now);
|
|
347
|
+
(result.state === 'deleted' ? report.deleted : report.cleanupPending).push(result);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return report;
|
|
351
|
+
};
|
|
352
|
+
const acquireAttemptSource = async (suppliedOwner) => {
|
|
353
|
+
if (!buildsEnabled())
|
|
354
|
+
throw new AttemptSourceError('whole_read_disabled');
|
|
355
|
+
const owner = normalizeOwner(suppliedOwner, clock().toISOString());
|
|
356
|
+
await ensureRoots();
|
|
357
|
+
const locationId = randomLocationId();
|
|
358
|
+
if (!LOCATION_ID.test(locationId))
|
|
359
|
+
throw new AttemptSourceError('source_owner_invalid');
|
|
360
|
+
const path = join(root, `source-${locationId}`);
|
|
361
|
+
const record = {
|
|
362
|
+
...owner,
|
|
363
|
+
locationId,
|
|
364
|
+
path,
|
|
365
|
+
state: 'live',
|
|
366
|
+
cleanupAttempts: 0,
|
|
367
|
+
errorCode: null,
|
|
368
|
+
deletedAt: null,
|
|
369
|
+
};
|
|
370
|
+
await mkdir(path, { recursive: false });
|
|
371
|
+
try {
|
|
372
|
+
await writeFile(join(path, MARKER_FILE), JSON.stringify(markerFor(owner, locationId)), {
|
|
373
|
+
encoding: 'utf8',
|
|
374
|
+
flag: 'wx',
|
|
375
|
+
});
|
|
376
|
+
await saveRecord(record);
|
|
377
|
+
activeLocations.add(locationId);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
await rm(path, { recursive: true, force: true });
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
let releasePromise = null;
|
|
384
|
+
const release = () => {
|
|
385
|
+
releasePromise ??= cleanupLocation(record, clock().toISOString());
|
|
386
|
+
return releasePromise;
|
|
387
|
+
};
|
|
388
|
+
return {
|
|
389
|
+
locationId,
|
|
390
|
+
path,
|
|
391
|
+
bareRepositoryPath: join(path, 'repo.git'),
|
|
392
|
+
owner,
|
|
393
|
+
registerReader(reader) {
|
|
394
|
+
if (releasePromise !== null || !activeLocations.has(locationId)) {
|
|
395
|
+
throw new AttemptSourceError('source_lease_lost');
|
|
396
|
+
}
|
|
397
|
+
const active = readers.get(locationId) ?? new Set();
|
|
398
|
+
active.add(reader);
|
|
399
|
+
readers.set(locationId, active);
|
|
400
|
+
return () => active.delete(reader);
|
|
401
|
+
},
|
|
402
|
+
async renew(leaseExpiresAt) {
|
|
403
|
+
if (releasePromise !== null || !activeLocations.has(locationId)) {
|
|
404
|
+
throw new AttemptSourceError('source_lease_lost');
|
|
405
|
+
}
|
|
406
|
+
const renewed = normalizedTime(leaseExpiresAt);
|
|
407
|
+
if (renewed <= clock().toISOString() || renewed < record.leaseExpiresAt) {
|
|
408
|
+
throw new AttemptSourceError('source_owner_invalid');
|
|
409
|
+
}
|
|
410
|
+
record.leaseExpiresAt = renewed;
|
|
411
|
+
owner.leaseExpiresAt = renewed;
|
|
412
|
+
await saveRecord(record);
|
|
413
|
+
},
|
|
414
|
+
release,
|
|
415
|
+
};
|
|
416
|
+
};
|
|
417
|
+
const withAttemptSource = async (suppliedOwner, action) => {
|
|
418
|
+
const lease = await acquireAttemptSource(suppliedOwner);
|
|
419
|
+
try {
|
|
420
|
+
return await action(lease);
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
const cleanup = await lease.release();
|
|
424
|
+
if (cleanup.state === 'cleanup_pending')
|
|
425
|
+
throw new AttemptSourceCleanupError(cleanup);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
const startAttemptSourceSweeper = () => {
|
|
429
|
+
if (timer !== null)
|
|
430
|
+
return Promise.resolve();
|
|
431
|
+
if (starting !== null)
|
|
432
|
+
return starting;
|
|
433
|
+
const startVersion = ++lifecycleVersion;
|
|
434
|
+
starting = (async () => {
|
|
435
|
+
await sweepAttemptSources(clock().toISOString());
|
|
436
|
+
if (timer !== null || lifecycleVersion !== startVersion)
|
|
437
|
+
return;
|
|
438
|
+
timer = scheduleInterval(() => {
|
|
439
|
+
if (sweepRunning)
|
|
440
|
+
return;
|
|
441
|
+
sweepRunning = true;
|
|
442
|
+
void sweepAttemptSources(clock().toISOString())
|
|
443
|
+
.catch(() => undefined)
|
|
444
|
+
.finally(() => { sweepRunning = false; });
|
|
445
|
+
}, SWEEP_INTERVAL_MS);
|
|
446
|
+
if (typeof timer === 'object' && timer !== null && 'unref' in timer)
|
|
447
|
+
timer.unref();
|
|
448
|
+
})().finally(() => { starting = null; });
|
|
449
|
+
return starting;
|
|
450
|
+
};
|
|
451
|
+
const stopAttemptSourceSweeper = () => {
|
|
452
|
+
lifecycleVersion += 1;
|
|
453
|
+
if (timer !== null)
|
|
454
|
+
cancelInterval(timer);
|
|
455
|
+
timer = null;
|
|
456
|
+
};
|
|
457
|
+
return {
|
|
458
|
+
acquireAttemptSource,
|
|
459
|
+
withAttemptSource,
|
|
460
|
+
sweepAttemptSources,
|
|
461
|
+
startAttemptSourceSweeper,
|
|
462
|
+
stopAttemptSourceSweeper,
|
|
463
|
+
sweeperActive: () => timer !== null,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
let configuredReporter = async () => false;
|
|
467
|
+
const companionAttemptSources = createCompanionAttemptSourceManager({
|
|
468
|
+
reportCleanup: (record) => configuredReporter(record),
|
|
469
|
+
});
|
|
470
|
+
/** B19 binds this owner-scoped delivery seam to the claimed build assignment transport. */
|
|
471
|
+
export function configureCompanionCleanupReporter(reporter) {
|
|
472
|
+
configuredReporter = reporter;
|
|
473
|
+
}
|
|
474
|
+
export const withAttemptSource = companionAttemptSources.withAttemptSource;
|
|
475
|
+
export const acquireAttemptSource = companionAttemptSources.acquireAttemptSource;
|
|
476
|
+
export const sweepAttemptSources = companionAttemptSources.sweepAttemptSources;
|
|
477
|
+
export const startAttemptSourceSweeper = companionAttemptSources.startAttemptSourceSweeper;
|
|
478
|
+
export const stopAttemptSourceSweeper = companionAttemptSources.stopAttemptSourceSweeper;
|
|
479
|
+
export const attemptSourceSweeperActive = companionAttemptSources.sweeperActive;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@withtruing/companion",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "The Truing companion: reads your code and runs AI on your own computer, for Truing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,8 +36,14 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@anthropic-ai/sdk": "^0.111.0",
|
|
39
|
+
"@yarnpkg/lockfile": "1.1.0",
|
|
39
40
|
"cross-spawn": "^7.0.6",
|
|
40
|
-
"
|
|
41
|
+
"jsonc-parser": "3.3.1",
|
|
42
|
+
"smol-toml": "1.9.0",
|
|
43
|
+
"tar-stream": "2.2.0",
|
|
44
|
+
"typescript": "5.9.3",
|
|
45
|
+
"yaml": "2.8.2",
|
|
46
|
+
"yauzl": "3.2.0"
|
|
41
47
|
},
|
|
42
48
|
"devDependencies": {
|
|
43
49
|
"@types/cross-spawn": "^6.0.6",
|