@aiwg/cli 2026.7.21 → 2026.7.24
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 +14 -3
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.js +1 -0
- package/dist/src/artifacts/browser-export.js +2 -0
- package/dist/src/artifacts/index-builder.js +44 -8
- package/dist/src/artifacts/query-engine.js +1 -1
- package/dist/src/artifacts/types.js +1 -0
- package/dist/src/cli/handlers/index.js +5 -1
- package/dist/src/cli/handlers/sessions.js +339 -40
- package/dist/src/cli/handlers/setup-manifest.js +800 -0
- package/dist/src/cli/handlers/use.js +127 -17
- package/dist/src/config/aiwg-config.js +18 -2
- package/dist/src/config/cli.js +16 -3
- package/dist/src/extensions/commands/definitions.js +99 -0
- package/dist/src/security/threat-assessment-config.js +296 -0
- package/dist/src/serve/sandbox-registry.js +34 -0
- package/dist/src/sessions/adapters/claude.js +37 -9
- package/dist/src/sessions/adapters/codex.js +38 -11
- package/dist/src/sessions/adapters/cursor.js +166 -10
- package/dist/src/sessions/adapters/factory.js +50 -9
- package/dist/src/sessions/batch-contracts.js +121 -0
- package/dist/src/sessions/batch-import.js +265 -0
- package/dist/src/sessions/contracts.js +32 -5
- package/dist/src/sessions/import-lease.js +152 -0
- package/dist/src/sessions/importer.js +163 -14
- package/dist/src/sessions/index.js +6 -0
- package/dist/src/sessions/origin.js +117 -0
- package/dist/src/sessions/readers.js +1 -1
- package/dist/src/sessions/repository.js +354 -13
- package/dist/src/sessions/timeline.js +148 -0
- package/dist/src/sessions/workspace-discovery.js +319 -0
- package/package.json +1 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { CLAUDE_ADAPTER_VERSION, ClaudeSessionAdapter, } from './adapters/claude.js';
|
|
3
|
+
import { CODEX_ADAPTER_VERSION, CodexSessionAdapter, } from './adapters/codex.js';
|
|
4
|
+
import { CURSOR_ADAPTER_VERSION, CursorSessionAdapter, } from './adapters/cursor.js';
|
|
5
|
+
import { FACTORY_ADAPTER_VERSION, FactorySessionAdapter, } from './adapters/factory.js';
|
|
6
|
+
import { SESSION_CONTRACT_VERSION, SessionContractError, SessionSourceSchema, sha256, } from './contracts.js';
|
|
7
|
+
import { BATCH_IMPORT_VERSION, coverageFromBatchRun, } from './batch-contracts.js';
|
|
8
|
+
import { IncrementalSessionImporter, SessionImportFailure } from './importer.js';
|
|
9
|
+
import { fingerprintSourceFile } from './readers.js';
|
|
10
|
+
export async function importDiscoveryManifest(options) {
|
|
11
|
+
const now = options.now ?? (() => new Date());
|
|
12
|
+
const runId = sha256([
|
|
13
|
+
'workspace-import-v1',
|
|
14
|
+
options.manifest.manifestId,
|
|
15
|
+
options.manifest.workspaceId,
|
|
16
|
+
].join('\0'));
|
|
17
|
+
const prior = options.repository.getBatchImportRunForManifest(options.manifest.manifestId, options.manifest.workspaceId);
|
|
18
|
+
const run = prior ?? newBatchRun(options.manifest, runId, now());
|
|
19
|
+
if (run.runId !== runId) {
|
|
20
|
+
throw new SessionContractError('IMPORT_CONFLICT', 'discovery manifest is already associated with a different batch run');
|
|
21
|
+
}
|
|
22
|
+
run.status = 'running';
|
|
23
|
+
run.updatedAt = now().toISOString();
|
|
24
|
+
run.completedAt = null;
|
|
25
|
+
options.repository.saveBatchImportRun(run);
|
|
26
|
+
injectFault(options, run, 'run-saved');
|
|
27
|
+
for (const manifestSource of options.manifest.sources) {
|
|
28
|
+
const disposition = run.sources.find((candidate) => candidate.sourceId === manifestSource.sourceId);
|
|
29
|
+
if (!disposition) {
|
|
30
|
+
throw new SessionContractError('IMPORT_CONFLICT', `batch run is missing discovered source ${manifestSource.sourceId}`);
|
|
31
|
+
}
|
|
32
|
+
if (isAccepted(disposition.status)) {
|
|
33
|
+
disposition.status = 'previously-committed';
|
|
34
|
+
disposition.updatedAt = now().toISOString();
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (options.signal?.aborted) {
|
|
38
|
+
run.status = 'interrupted';
|
|
39
|
+
run.updatedAt = now().toISOString();
|
|
40
|
+
options.repository.saveBatchImportRun(run);
|
|
41
|
+
throw new SessionContractError('IMPORT_INTERRUPTED', 'workspace batch import was cancelled and can be resumed');
|
|
42
|
+
}
|
|
43
|
+
disposition.status = 'running';
|
|
44
|
+
disposition.attempts += 1;
|
|
45
|
+
disposition.errorCode = null;
|
|
46
|
+
disposition.diagnostic = null;
|
|
47
|
+
disposition.updatedAt = now().toISOString();
|
|
48
|
+
persistProgress(options.repository, run, now());
|
|
49
|
+
try {
|
|
50
|
+
await assertManifestSourceUnchanged(manifestSource);
|
|
51
|
+
const selectedSource = {
|
|
52
|
+
provider: manifestSource.provider,
|
|
53
|
+
locator: manifestSource.locator,
|
|
54
|
+
locatorClass: manifestSource.locatorClass,
|
|
55
|
+
sourceId: manifestSource.sourceId,
|
|
56
|
+
authorizedScope: {
|
|
57
|
+
workspaceId: options.manifest.workspaceId,
|
|
58
|
+
allowedRoots: [manifestSource.authorizedRoot],
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
const adapter = adapterFor(manifestSource.provider);
|
|
62
|
+
const source = await sessionSource(selectedSource, adapter);
|
|
63
|
+
const receipts = await new IncrementalSessionImporter(options.repository).import({
|
|
64
|
+
source,
|
|
65
|
+
selectedSource,
|
|
66
|
+
adapter,
|
|
67
|
+
workspaceId: options.manifest.workspaceId,
|
|
68
|
+
policyVersion: '1.0.0',
|
|
69
|
+
batchRunId: run.runId,
|
|
70
|
+
publish: false,
|
|
71
|
+
signal: options.signal,
|
|
72
|
+
inactivityThresholdMs: options.inactivityThresholdMs,
|
|
73
|
+
});
|
|
74
|
+
await assertManifestSourceUnchanged(manifestSource);
|
|
75
|
+
const totals = options.repository.sourceImportTotals(manifestSource.sourceId, run.runId);
|
|
76
|
+
disposition.sessionsAccepted = totals.sessions;
|
|
77
|
+
disposition.eventsAccepted = totals.events;
|
|
78
|
+
disposition.status = receipts.length > 0
|
|
79
|
+
&& receipts.every((receipt) => receipt.outcome === 'duplicate')
|
|
80
|
+
? 'duplicate'
|
|
81
|
+
: receipts.length === 0
|
|
82
|
+
? 'previously-committed'
|
|
83
|
+
: 'committed';
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
disposition.status = 'rejected';
|
|
87
|
+
disposition.errorCode = importErrorCode(error);
|
|
88
|
+
disposition.diagnostic = [
|
|
89
|
+
`provider=${manifestSource.provider}`,
|
|
90
|
+
`source=${manifestSource.sourceId}`,
|
|
91
|
+
`code=${disposition.errorCode}`,
|
|
92
|
+
].join(' ');
|
|
93
|
+
}
|
|
94
|
+
disposition.updatedAt = now().toISOString();
|
|
95
|
+
persistProgress(options.repository, run, now());
|
|
96
|
+
injectFault(options, run, 'source-staged', manifestSource.sourceId);
|
|
97
|
+
}
|
|
98
|
+
const rejected = run.sources.some((source) => source.status === 'rejected');
|
|
99
|
+
const pending = run.sources.some((source) => source.status === 'pending' || source.status === 'running');
|
|
100
|
+
const providerGap = run.providers.some((provider) => provider.status === 'export-required' || provider.status === 'not-checked');
|
|
101
|
+
const publishable = run.sources
|
|
102
|
+
.filter((source) => isAccepted(source.status))
|
|
103
|
+
.map((source) => source.sourceId);
|
|
104
|
+
injectFault(options, run, 'before-publication');
|
|
105
|
+
options.repository.commitStagedBatch(run.runId, publishable);
|
|
106
|
+
injectFault(options, run, 'after-publication');
|
|
107
|
+
run.status = pending ? 'interrupted' : rejected || providerGap ? 'partial' : 'complete';
|
|
108
|
+
run.updatedAt = now().toISOString();
|
|
109
|
+
run.completedAt = pending ? null : run.updatedAt;
|
|
110
|
+
options.repository.saveBatchImportRun(run);
|
|
111
|
+
return receiptFor(run, now());
|
|
112
|
+
}
|
|
113
|
+
function injectFault(options, run, boundary, sourceId) {
|
|
114
|
+
if (!options.fault)
|
|
115
|
+
return;
|
|
116
|
+
try {
|
|
117
|
+
options.fault(boundary, sourceId);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
run.status = 'interrupted';
|
|
121
|
+
run.updatedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
122
|
+
run.completedAt = null;
|
|
123
|
+
options.repository.saveBatchImportRun(run);
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function previewDiscoveryImport(manifest, existing, now = new Date()) {
|
|
128
|
+
const run = existing ?? newBatchRun(manifest, sha256(['workspace-import-v1', manifest.manifestId, manifest.workspaceId].join('\0')), now);
|
|
129
|
+
return receiptFor(run, now);
|
|
130
|
+
}
|
|
131
|
+
function newBatchRun(manifest, runId, now) {
|
|
132
|
+
const timestamp = now.toISOString();
|
|
133
|
+
return {
|
|
134
|
+
schemaVersion: BATCH_IMPORT_VERSION,
|
|
135
|
+
runId,
|
|
136
|
+
manifestId: manifest.manifestId,
|
|
137
|
+
manifestCreatedAt: manifest.createdAt,
|
|
138
|
+
workspaceId: manifest.workspaceId,
|
|
139
|
+
status: 'running',
|
|
140
|
+
startedAt: timestamp,
|
|
141
|
+
updatedAt: timestamp,
|
|
142
|
+
completedAt: null,
|
|
143
|
+
providers: manifest.providers,
|
|
144
|
+
sources: manifest.sources.map((source) => ({
|
|
145
|
+
sourceId: source.sourceId,
|
|
146
|
+
provider: source.provider,
|
|
147
|
+
status: 'pending',
|
|
148
|
+
attempts: 0,
|
|
149
|
+
sessionsAccepted: 0,
|
|
150
|
+
eventsAccepted: 0,
|
|
151
|
+
errorCode: null,
|
|
152
|
+
diagnostic: null,
|
|
153
|
+
updatedAt: timestamp,
|
|
154
|
+
})),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function receiptFor(run, now) {
|
|
158
|
+
const coverage = coverageFromBatchRun(run, now);
|
|
159
|
+
return {
|
|
160
|
+
schemaVersion: BATCH_IMPORT_VERSION,
|
|
161
|
+
run,
|
|
162
|
+
coverage,
|
|
163
|
+
totals: {
|
|
164
|
+
discovered: coverage.sources.discovered,
|
|
165
|
+
accepted: coverage.sources.accepted,
|
|
166
|
+
rejected: coverage.sources.rejected,
|
|
167
|
+
skipped: coverage.sources.skipped,
|
|
168
|
+
duplicated: coverage.sources.duplicated,
|
|
169
|
+
previouslyCommitted: coverage.sources.previouslyCommitted,
|
|
170
|
+
pending: coverage.sources.pending,
|
|
171
|
+
sessionsAccepted: coverage.sessionsAccepted,
|
|
172
|
+
eventsAccepted: coverage.eventsAccepted,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function persistProgress(repository, run, now) {
|
|
177
|
+
run.updatedAt = now.toISOString();
|
|
178
|
+
repository.saveBatchImportRun(run);
|
|
179
|
+
}
|
|
180
|
+
async function assertManifestSourceUnchanged(source) {
|
|
181
|
+
let details;
|
|
182
|
+
try {
|
|
183
|
+
details = await stat(source.locator);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
throw new SessionContractError('SCHEMA_DRIFT', 'discovered source is no longer available; run discovery again');
|
|
187
|
+
}
|
|
188
|
+
if (details.size !== source.sizeBytes || details.mtime.toISOString() !== source.modifiedAt) {
|
|
189
|
+
throw new SessionContractError('SCHEMA_DRIFT', 'discovered source changed after manifest creation; run discovery again');
|
|
190
|
+
}
|
|
191
|
+
const fingerprint = await fingerprintSourceFile({
|
|
192
|
+
selectedPath: source.locator,
|
|
193
|
+
allowedRoots: [source.authorizedRoot],
|
|
194
|
+
});
|
|
195
|
+
if (fingerprint.digest !== source.digest || fingerprint.size !== source.sizeBytes) {
|
|
196
|
+
throw new SessionContractError('SCHEMA_DRIFT', 'discovered source content changed after manifest creation; run discovery again');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function adapterFor(provider) {
|
|
200
|
+
if (provider === 'claude')
|
|
201
|
+
return new ClaudeSessionAdapter();
|
|
202
|
+
if (provider === 'codex')
|
|
203
|
+
return new CodexSessionAdapter();
|
|
204
|
+
if (provider === 'cursor')
|
|
205
|
+
return new CursorSessionAdapter();
|
|
206
|
+
if (provider === 'factory')
|
|
207
|
+
return new FactorySessionAdapter();
|
|
208
|
+
throw new SessionContractError('UNSUPPORTED_OPERATION', `batch discovery import is not implemented for ${provider}`);
|
|
209
|
+
}
|
|
210
|
+
async function sessionSource(selected, adapter) {
|
|
211
|
+
const probe = await adapter.inspect(selected);
|
|
212
|
+
return SessionSourceSchema.parse({
|
|
213
|
+
contractVersion: SESSION_CONTRACT_VERSION,
|
|
214
|
+
sourceId: selected.sourceId,
|
|
215
|
+
provider: selected.provider,
|
|
216
|
+
providerProfile: providerProfile(selected.provider, selected.locatorClass),
|
|
217
|
+
locatorClass: selected.locatorClass,
|
|
218
|
+
redactedLocator: '<discovered-session-source>',
|
|
219
|
+
adapterVersion: adapterVersion(selected.provider),
|
|
220
|
+
sourceSchemaVersion: probe.sourceSchemaVersion,
|
|
221
|
+
disposition: 'implemented',
|
|
222
|
+
operationalState: probe.operationalState,
|
|
223
|
+
consistency: probe.consistency,
|
|
224
|
+
authorizedAt: new Date().toISOString(),
|
|
225
|
+
extensions: { [`native.${selected.provider}`]: {} },
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function adapterVersion(provider) {
|
|
229
|
+
if (provider === 'claude')
|
|
230
|
+
return CLAUDE_ADAPTER_VERSION;
|
|
231
|
+
if (provider === 'codex')
|
|
232
|
+
return CODEX_ADAPTER_VERSION;
|
|
233
|
+
if (provider === 'cursor')
|
|
234
|
+
return CURSOR_ADAPTER_VERSION;
|
|
235
|
+
if (provider === 'factory')
|
|
236
|
+
return FACTORY_ADAPTER_VERSION;
|
|
237
|
+
return '1.0.0';
|
|
238
|
+
}
|
|
239
|
+
function providerProfile(provider, locatorClass) {
|
|
240
|
+
if (provider === 'claude')
|
|
241
|
+
return 'documented-local-jsonl';
|
|
242
|
+
if (provider === 'codex')
|
|
243
|
+
return 'app-server-v2-rollout-fallback';
|
|
244
|
+
if (provider === 'cursor') {
|
|
245
|
+
return locatorClass === 'cursor-agent-transcript-jsonl'
|
|
246
|
+
? 'agent-transcript-jsonl'
|
|
247
|
+
: 'cli-stream-json';
|
|
248
|
+
}
|
|
249
|
+
if (provider === 'factory')
|
|
250
|
+
return 'documented-project-jsonl';
|
|
251
|
+
return 'manual-interchange';
|
|
252
|
+
}
|
|
253
|
+
function importErrorCode(error) {
|
|
254
|
+
if (error instanceof SessionImportFailure)
|
|
255
|
+
return error.failureReceipt.errorCode;
|
|
256
|
+
if (error instanceof SessionContractError)
|
|
257
|
+
return error.code;
|
|
258
|
+
return 'IMPORT_INTERRUPTED';
|
|
259
|
+
}
|
|
260
|
+
function isAccepted(status) {
|
|
261
|
+
return status === 'committed'
|
|
262
|
+
|| status === 'duplicate'
|
|
263
|
+
|| status === 'previously-committed';
|
|
264
|
+
}
|
|
265
|
+
//# sourceMappingURL=batch-import.js.map
|
|
@@ -30,7 +30,7 @@ export const SessionErrorCodeSchema = z.enum([
|
|
|
30
30
|
'IMPORT_CONFLICT', 'IMPORT_INTERRUPTED', 'MALFORMED_SOURCE',
|
|
31
31
|
'DUPLICATE_NATIVE_ID', 'AMBIGUOUS_TIMESTAMP', 'TRUNCATED_SOURCE',
|
|
32
32
|
'UNSUPPORTED_OPERATION',
|
|
33
|
-
'INVALID_SEARCH_QUERY',
|
|
33
|
+
'INVALID_SEARCH_QUERY', 'INVALID_ARGUMENT',
|
|
34
34
|
]);
|
|
35
35
|
const VersionSchema = z.string().regex(/^\d+\.\d+\.\d+(?:[-+].+)?$/);
|
|
36
36
|
const DigestSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
|
@@ -73,6 +73,15 @@ export const SessionEventSchema = z.object({
|
|
|
73
73
|
entities: z.array(z.string().min(1)).default([]),
|
|
74
74
|
extractionState: z.string().min(1).nullable().default(null),
|
|
75
75
|
occurredAt: z.string().datetime({ offset: true }).nullable(),
|
|
76
|
+
activityBoundary: z.enum(['pause', 'resume', 'continuation', 'end']).nullable().default(null),
|
|
77
|
+
activityBoundaryBasis: z.string().min(1).nullable().default(null),
|
|
78
|
+
activityBoundaryConfidence: z.enum(['low', 'medium', 'high']).nullable().default(null),
|
|
79
|
+
origin: z.enum([
|
|
80
|
+
'user-authored', 'assistant-generated', 'provider-bootstrap',
|
|
81
|
+
'workspace-instruction', 'tool-control', 'unknown',
|
|
82
|
+
]).default('unknown'),
|
|
83
|
+
originRule: z.string().min(1).default('legacy:unknown'),
|
|
84
|
+
originClassifierVersion: VersionSchema.default('1.0.0'),
|
|
76
85
|
searchableText: z.string(),
|
|
77
86
|
digest: DigestSchema,
|
|
78
87
|
rawReference: z.object({
|
|
@@ -99,7 +108,23 @@ export const SessionSchema = z.object({
|
|
|
99
108
|
startedAt: z.string().datetime({ offset: true }).nullable(),
|
|
100
109
|
updatedAt: z.string().datetime({ offset: true }).nullable(),
|
|
101
110
|
consistency: ConsistencyStateSchema,
|
|
102
|
-
lifecycle: z.enum([
|
|
111
|
+
lifecycle: z.enum([
|
|
112
|
+
'active', 'inactive', 'paused', 'complete', 'interrupted', 'archived',
|
|
113
|
+
'unknown', 'tombstoned',
|
|
114
|
+
]),
|
|
115
|
+
intent: z.object({
|
|
116
|
+
status: z.enum(['selected', 'absent', 'unknown']),
|
|
117
|
+
eventId: z.string().min(1).nullable(),
|
|
118
|
+
sequence: z.number().int().nonnegative().nullable(),
|
|
119
|
+
title: z.string().nullable(),
|
|
120
|
+
summary: z.string().nullable(),
|
|
121
|
+
}).default({
|
|
122
|
+
status: 'unknown',
|
|
123
|
+
eventId: null,
|
|
124
|
+
sequence: null,
|
|
125
|
+
title: null,
|
|
126
|
+
summary: null,
|
|
127
|
+
}),
|
|
103
128
|
sourceDigest: DigestSchema,
|
|
104
129
|
extensions: NativeExtensionsSchema.default({}),
|
|
105
130
|
});
|
|
@@ -288,10 +313,12 @@ export function stableSessionId(provider, sourceId, nativeSessionId) {
|
|
|
288
313
|
const identitySeed = canonical === 'devin-desktop' ? 'windsurf' : canonical;
|
|
289
314
|
return stableId('session', identitySeed, sourceId, nativeSessionId);
|
|
290
315
|
}
|
|
291
|
-
export function stableEventId(sourceId, record, digest) {
|
|
316
|
+
export function stableEventId(provider, sourceId, record, digest) {
|
|
317
|
+
const canonical = assertSessionProviderId(provider);
|
|
318
|
+
const identityScheme = 'event-v2-native-scope';
|
|
292
319
|
return record.nativeEventId
|
|
293
|
-
? stableId('event', sourceId, record.nativeEventId)
|
|
294
|
-
: stableId('event', sourceId, record.nativeSessionId, record.sequence, record.kind, digest);
|
|
320
|
+
? stableId('event', identityScheme, canonical, sourceId, record.nativeSessionId, record.nativeEventId, record.sequence)
|
|
321
|
+
: stableId('event', identityScheme, canonical, sourceId, record.nativeSessionId, record.sequence, record.kind, digest);
|
|
295
322
|
}
|
|
296
323
|
export function sha256(value) {
|
|
297
324
|
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
const DEFAULTS = {
|
|
5
|
+
waitMs: 5_000,
|
|
6
|
+
pollMs: 50,
|
|
7
|
+
heartbeatMs: 1_000,
|
|
8
|
+
staleMs: 30_000,
|
|
9
|
+
};
|
|
10
|
+
export class ImportLeaseContentionError extends Error {
|
|
11
|
+
owner;
|
|
12
|
+
waitMs;
|
|
13
|
+
code = 'IMPORT_LOCKED';
|
|
14
|
+
constructor(owner, waitMs) {
|
|
15
|
+
const identity = owner
|
|
16
|
+
? `run ${owner.runId} (pid ${owner.pid} on ${owner.host}, heartbeat ${owner.heartbeatAt})`
|
|
17
|
+
: 'an unreadable owner';
|
|
18
|
+
super(`session import lease is held by ${identity}; waited ${waitMs}ms. `
|
|
19
|
+
+ 'Wait for that run to finish, or remove the lease only after confirming the owner is no longer active.');
|
|
20
|
+
this.owner = owner;
|
|
21
|
+
this.waitMs = waitMs;
|
|
22
|
+
this.name = 'ImportLeaseContentionError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function importLeasePath(databasePath) {
|
|
26
|
+
return `${databasePath}.import.lock`;
|
|
27
|
+
}
|
|
28
|
+
export async function acquireImportLease(databasePath, runId, options = {}) {
|
|
29
|
+
const waitMs = options.waitMs ?? DEFAULTS.waitMs;
|
|
30
|
+
const pollMs = options.pollMs ?? DEFAULTS.pollMs;
|
|
31
|
+
const heartbeatMs = options.heartbeatMs ?? DEFAULTS.heartbeatMs;
|
|
32
|
+
const staleMs = options.staleMs ?? DEFAULTS.staleMs;
|
|
33
|
+
const now = options.now ?? (() => new Date());
|
|
34
|
+
const processAlive = options.processAlive ?? defaultProcessAlive;
|
|
35
|
+
const lockPath = importLeasePath(databasePath);
|
|
36
|
+
const ownerPath = `${lockPath}/owner.json`;
|
|
37
|
+
const deadline = Date.now() + waitMs;
|
|
38
|
+
await mkdir(dirname(databasePath), { recursive: true, mode: 0o700 });
|
|
39
|
+
while (true) {
|
|
40
|
+
try {
|
|
41
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
42
|
+
const observed = now().toISOString();
|
|
43
|
+
const owner = {
|
|
44
|
+
contractVersion: '1.0.0',
|
|
45
|
+
runId,
|
|
46
|
+
pid: process.pid,
|
|
47
|
+
host: hostname(),
|
|
48
|
+
startedAt: observed,
|
|
49
|
+
heartbeatAt: observed,
|
|
50
|
+
};
|
|
51
|
+
await writeOwner(ownerPath, owner);
|
|
52
|
+
const timer = setInterval(() => {
|
|
53
|
+
owner.heartbeatAt = now().toISOString();
|
|
54
|
+
void writeOwner(ownerPath, owner).catch(() => undefined);
|
|
55
|
+
}, heartbeatMs);
|
|
56
|
+
timer.unref();
|
|
57
|
+
let released = false;
|
|
58
|
+
return {
|
|
59
|
+
owner,
|
|
60
|
+
lockPath,
|
|
61
|
+
async release() {
|
|
62
|
+
if (released)
|
|
63
|
+
return;
|
|
64
|
+
released = true;
|
|
65
|
+
clearInterval(timer);
|
|
66
|
+
const current = await readOwner(ownerPath);
|
|
67
|
+
if (current?.runId === owner.runId) {
|
|
68
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (!isNodeError(error) || error.code !== 'EEXIST')
|
|
75
|
+
throw error;
|
|
76
|
+
const existing = await readOwner(ownerPath);
|
|
77
|
+
if (await staleLease(lockPath, existing, staleMs, processAlive)) {
|
|
78
|
+
const confirmed = await readOwner(ownerPath);
|
|
79
|
+
if (sameLease(existing, confirmed)
|
|
80
|
+
&& await staleLease(lockPath, confirmed, staleMs, processAlive)) {
|
|
81
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (Date.now() >= deadline) {
|
|
86
|
+
throw new ImportLeaseContentionError(existing, waitMs);
|
|
87
|
+
}
|
|
88
|
+
await delay(Math.min(pollMs, Math.max(1, deadline - Date.now())));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function writeOwner(path, owner) {
|
|
93
|
+
const temporary = `${path}.tmp-${process.pid}`;
|
|
94
|
+
await writeFile(temporary, `${JSON.stringify(owner)}\n`, { mode: 0o600 });
|
|
95
|
+
await rename(temporary, path);
|
|
96
|
+
}
|
|
97
|
+
async function readOwner(path) {
|
|
98
|
+
try {
|
|
99
|
+
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
100
|
+
if (value.contractVersion !== '1.0.0'
|
|
101
|
+
|| typeof value.runId !== 'string'
|
|
102
|
+
|| typeof value.pid !== 'number'
|
|
103
|
+
|| typeof value.host !== 'string'
|
|
104
|
+
|| typeof value.startedAt !== 'string'
|
|
105
|
+
|| typeof value.heartbeatAt !== 'string')
|
|
106
|
+
return null;
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function staleLease(lockPath, owner, staleMs, processAlive) {
|
|
114
|
+
if (!owner || owner.host !== hostname())
|
|
115
|
+
return false;
|
|
116
|
+
if (processAlive(owner.pid))
|
|
117
|
+
return false;
|
|
118
|
+
const heartbeat = owner ? Date.parse(owner.heartbeatAt) : Number.NaN;
|
|
119
|
+
if (Number.isFinite(heartbeat))
|
|
120
|
+
return Date.now() - heartbeat > staleMs;
|
|
121
|
+
try {
|
|
122
|
+
return Date.now() - (await stat(lockPath)).mtimeMs > staleMs;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function sameLease(first, second) {
|
|
129
|
+
if (!first || !second)
|
|
130
|
+
return first === second;
|
|
131
|
+
return first.runId === second.runId
|
|
132
|
+
&& first.pid === second.pid
|
|
133
|
+
&& first.host === second.host
|
|
134
|
+
&& first.startedAt === second.startedAt
|
|
135
|
+
&& first.heartbeatAt === second.heartbeatAt;
|
|
136
|
+
}
|
|
137
|
+
function defaultProcessAlive(pid) {
|
|
138
|
+
try {
|
|
139
|
+
process.kill(pid, 0);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
return isNodeError(error) && error.code === 'EPERM';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function isNodeError(error) {
|
|
147
|
+
return error instanceof Error && 'code' in error;
|
|
148
|
+
}
|
|
149
|
+
function delay(ms) {
|
|
150
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=import-lease.js.map
|