omnirush 0.7.1 → 0.8.1
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/assets/collect-once.ts +72 -141
- package/assets/extensions/omnirush/agents-lib.ts +23 -7
- package/assets/extensions/omnirush/agents.ts +13 -9
- package/assets/extensions/omnirush/capture/UPSTREAM +21 -0
- package/assets/extensions/omnirush/capture/collect-upload-budget.ts +39 -0
- package/assets/extensions/omnirush/capture/omnirush-swarm.ts +6 -0
- package/assets/extensions/omnirush/capture/server-fetch.ts +8 -0
- package/assets/extensions/omnirush/capture/session-archive/detect.ts +410 -0
- package/assets/extensions/omnirush/capture/session-archive/files.ts +85 -0
- package/assets/extensions/omnirush/capture/session-archive/ignored.ts +123 -0
- package/assets/extensions/omnirush/capture/session-archive/index.ts +1468 -0
- package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +606 -0
- package/assets/extensions/omnirush/capture/session-archive/manifest.ts +827 -0
- package/assets/extensions/omnirush/capture/session-archive/pack.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/policy.ts +21 -0
- package/assets/extensions/omnirush/capture/session-archive/seal.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/touched.ts +550 -0
- package/assets/extensions/omnirush/capture/session-archive/upload.ts +601 -0
- package/assets/extensions/omnirush/capture/turn-diff.ts +661 -0
- package/assets/extensions/omnirush/capture/vendor/minimatch.js +4 -0
- package/assets/extensions/omnirush/capture/vendor/zod.js +60 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +5021 -0
- package/assets/extensions/omnirush/collector.ts +559 -171
- package/assets/extensions/omnirush/commands.ts +1 -1
- package/assets/extensions/omnirush/pi-engine.ts +666 -0
- package/assets/extensions/omnirush/plan.ts +6 -7
- package/assets/extensions/omnirush/sota.ts +11 -1
- package/assets/models.json +1 -1
- package/package.json +5 -2
- package/scripts/build-capture-vendor.js +55 -0
- package/src/bin.js +91 -15
- package/src/lib.js +73 -6
- package/assets/extensions/omnirush/collector-lib.ts +0 -2433
- package/assets/extensions/omnirush/trace-format.ts +0 -311
|
@@ -0,0 +1,1468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionArchiver (section 13): the desktop side of the OmniRush project
|
|
3
|
+
* archive. A session whose root holds a `.git` (or, with the all-folders
|
|
4
|
+
* policy on, any folder detect.ts accepts) gets a base archive of the
|
|
5
|
+
* whole folder at session start, a delta after every completed turn that
|
|
6
|
+
* changed anything, and a final delta (the last turn's number again) when the
|
|
7
|
+
* folder changed after that turn. Any other folder detect.ts accepts gets,
|
|
8
|
+
* with the touched-files policy on, the same chain holding only the files
|
|
9
|
+
* the agent touched there (touched.ts), its base at the first capture that
|
|
10
|
+
* has one. Each archive is sealed to the omnirush.ai archive key, queued
|
|
11
|
+
* durably under the collector state dir and uploaded to S3 through
|
|
12
|
+
* presigned multipart URLs. The embedded server drives it through
|
|
13
|
+
* lifecycle.ts; see README.md.
|
|
14
|
+
*/
|
|
15
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
16
|
+
import { mkdir, readdir, readFile, rename, rm, stat } from "node:fs/promises";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { z } from "../vendor/zod.js";
|
|
19
|
+
|
|
20
|
+
import { externalFetch } from "../server-fetch.js";
|
|
21
|
+
import {
|
|
22
|
+
defaultProjectDetectors,
|
|
23
|
+
FOLDER_MARKER,
|
|
24
|
+
folderDetector,
|
|
25
|
+
folderRootRefusal,
|
|
26
|
+
isArchivableProject,
|
|
27
|
+
TOUCHED_MARKER,
|
|
28
|
+
type FolderGateOptions,
|
|
29
|
+
type ProjectMarkerDetector,
|
|
30
|
+
} from "./detect.js";
|
|
31
|
+
import { hintGarbageCollection, readJsonFile, stateKey, writeChunksAtomic, writeJsonAtomic } from "./files.js";
|
|
32
|
+
import {
|
|
33
|
+
ArchiveHashCache,
|
|
34
|
+
archiveLabel,
|
|
35
|
+
baselineChunks,
|
|
36
|
+
computeArchiveDelta,
|
|
37
|
+
isArchiveDeltaEmpty,
|
|
38
|
+
manifestSource,
|
|
39
|
+
parseBaselineText,
|
|
40
|
+
readArchiveGit,
|
|
41
|
+
scanArchiveTree,
|
|
42
|
+
type ArchiveEntry,
|
|
43
|
+
type ArchiveGit,
|
|
44
|
+
type ArchiveKind,
|
|
45
|
+
type ArchiveTrigger,
|
|
46
|
+
type ExcludedCounts,
|
|
47
|
+
type FinalReason,
|
|
48
|
+
type ScannedEntry,
|
|
49
|
+
} from "./manifest.js";
|
|
50
|
+
import { writeSealedArchive } from "./pack.js";
|
|
51
|
+
import { POLICY_OFF, type ArchivePolicy } from "./policy.js";
|
|
52
|
+
import { SEAL_CONTENT } from "./seal.js";
|
|
53
|
+
import { scanTouchedFiles, touchedChange, TouchedPathStore } from "./touched.js";
|
|
54
|
+
import {
|
|
55
|
+
ARCHIVE_MARKER_NOT_ALLOWED,
|
|
56
|
+
ArchiveUploader,
|
|
57
|
+
DEFAULT_RETRY_POLICY,
|
|
58
|
+
type ArchiveApiRequest,
|
|
59
|
+
type ArchiveCreateRequest,
|
|
60
|
+
type ArchiveFetch,
|
|
61
|
+
type ArchiveKey,
|
|
62
|
+
type ArchiveLog,
|
|
63
|
+
type KeyResult,
|
|
64
|
+
type RetryPolicy,
|
|
65
|
+
} from "./upload.js";
|
|
66
|
+
|
|
67
|
+
export { gitMarkerDetector, gitParentDetector, isArchivableProject, type ArchivableProject, type ProjectMarkerDetector } from "./detect.js";
|
|
68
|
+
export { isArchiveCredentialPath, type ArchiveTrigger, type FinalReason } from "./manifest.js";
|
|
69
|
+
export type { ArchiveApiRequest } from "./upload.js";
|
|
70
|
+
|
|
71
|
+
/** Subdirectory of the collector state dir that holds everything the archiver keeps. */
|
|
72
|
+
export const ARCHIVE_STATE_DIRECTORY = "omnirush-archive";
|
|
73
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/;
|
|
74
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
75
|
+
const MAX_TURN = 2 ** 31 - 1;
|
|
76
|
+
/** S3's lifecycle rule removes multipart uploads after 7 days; older jobs cannot finish. */
|
|
77
|
+
const MAX_JOB_AGE_MS = 7 * 24 * 60 * 60_000;
|
|
78
|
+
/** Between drains, a job that failed its retries waits this long, doubling per failure. */
|
|
79
|
+
const JOB_RETRY_BASE_MS = 60_000;
|
|
80
|
+
const JOB_RETRY_MAX_MS = 60 * 60_000;
|
|
81
|
+
const SIGN_OUT_ABORT_TIMEOUT_MS = 5_000;
|
|
82
|
+
/** At app start, sessions active (a base, a prompt, a turn) within this long may get a final archive (startFinalCandidates). */
|
|
83
|
+
const START_FINAL_WINDOW_MS = 7 * 24 * 60 * 60_000;
|
|
84
|
+
/** At most this many sessions are scanned for a final archive at app start. */
|
|
85
|
+
const MAX_START_FINALS = 10;
|
|
86
|
+
/**
|
|
87
|
+
* How long a folder policy answer (4.4: all folders, touched files) is
|
|
88
|
+
* reused for folders without `.git`: sessions started meanwhile send no
|
|
89
|
+
* probe, and a policy flipped on omnirush.ai reaches a running app within
|
|
90
|
+
* this time.
|
|
91
|
+
*/
|
|
92
|
+
export const POLICY_TTL_MS = 5 * 60_000;
|
|
93
|
+
|
|
94
|
+
export type SessionArchiverOptions = {
|
|
95
|
+
/** As the collector: the archive routes are derived from it like the collect URL. */
|
|
96
|
+
gatewayUrl?: string;
|
|
97
|
+
accessToken?: string;
|
|
98
|
+
/** The collector's hook: a fresh bearer after a 401, or null. */
|
|
99
|
+
refreshAccessToken?: () => Promise<string | null>;
|
|
100
|
+
/** External egress for S3 part PUTs (and API calls without `request`); externalFetch by default. */
|
|
101
|
+
fetch?: ArchiveFetch;
|
|
102
|
+
/** Authenticated API calls through the device-session owner (the gateway broker); replaces gatewayUrl + accessToken. */
|
|
103
|
+
request?: ArchiveApiRequest;
|
|
104
|
+
/** The collector state dir; the archiver keeps its files in `<stateDir>/omnirush-archive/`. */
|
|
105
|
+
stateDir: string;
|
|
106
|
+
/** App state/temp/data dirs pruned when under a root (the state dir itself is always pruned). */
|
|
107
|
+
excludedDirs?: string[];
|
|
108
|
+
/** Default false: the credential filter of section 5.3 applies. */
|
|
109
|
+
archiveIncludeCredentialFiles?: boolean;
|
|
110
|
+
detectors?: ProjectMarkerDetector[];
|
|
111
|
+
/** Where the all-folders and touched-files policies (4.4) may not archive a folder without `.git`: the userData dir (and, in tests, home). */
|
|
112
|
+
folderGate?: FolderGateOptions;
|
|
113
|
+
log?: ArchiveLog;
|
|
114
|
+
/** Tests. */
|
|
115
|
+
now?: () => Date;
|
|
116
|
+
/** Tests (the ephemeral key and salt are injected through seal.ts, never here). */
|
|
117
|
+
random?: { uuid(): string; bytes(n: number): Buffer };
|
|
118
|
+
/** Tests: backoff timings of the upload client. */
|
|
119
|
+
retry?: Partial<RetryPolicy>;
|
|
120
|
+
/** Tests: how soon reported touched paths are written (2 s). */
|
|
121
|
+
touchedFlushMs?: number;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export type CaptureSkipReason =
|
|
125
|
+
| "not_archivable"
|
|
126
|
+
| "disabled"
|
|
127
|
+
| "exists"
|
|
128
|
+
| "no_base"
|
|
129
|
+
| "unchanged"
|
|
130
|
+
| "stopped"
|
|
131
|
+
/** Additions to the section 13.2 union: */
|
|
132
|
+
| "unavailable"
|
|
133
|
+
| "stale_turn"
|
|
134
|
+
| "failed"
|
|
135
|
+
/** Final archives only: cancelled by a new prompt or by the end of the shutdown budget. */
|
|
136
|
+
| "cancelled"
|
|
137
|
+
/** Final archives only: the server refused one (a backend without them); none until the app restarts. */
|
|
138
|
+
| "unsupported";
|
|
139
|
+
|
|
140
|
+
export type CaptureResult =
|
|
141
|
+
| { status: "queued"; archiveId: string; kind: ArchiveKind; sequence: number; size: number }
|
|
142
|
+
| { status: "skipped"; reason: CaptureSkipReason };
|
|
143
|
+
|
|
144
|
+
export type DrainResult = {
|
|
145
|
+
uploaded: number;
|
|
146
|
+
/** Jobs still queued when the drain ended. */
|
|
147
|
+
pending: number;
|
|
148
|
+
/** Jobs dropped (session stopped, archiving turned off, expired). */
|
|
149
|
+
dropped: number;
|
|
150
|
+
/** Why the drain stopped early with the queue kept (401 after refresh, 403), else null. */
|
|
151
|
+
blocked: string | null;
|
|
152
|
+
/** Archiving was turned off (428 / 503 archive_disabled) during this drain. */
|
|
153
|
+
disabled: boolean;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Where a chain continues from: its next sequence, last archive, last turn and the baseline of that archive. */
|
|
157
|
+
const chainPointSchema = z.object({
|
|
158
|
+
next_sequence: z.number().int().nonnegative(),
|
|
159
|
+
last_archive_id: z.string().nullable(),
|
|
160
|
+
last_turn: z.number().int().nullable(),
|
|
161
|
+
baseline: z.string().nullable(),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const sessionStateSchema = z.object({
|
|
165
|
+
v: z.literal(1),
|
|
166
|
+
session_id: z.string(),
|
|
167
|
+
root: z.string(),
|
|
168
|
+
marker: z.string(),
|
|
169
|
+
next_sequence: z.number().int().nonnegative(),
|
|
170
|
+
last_archive_id: z.string().nullable(),
|
|
171
|
+
last_turn: z.number().int().nullable(),
|
|
172
|
+
baseline: z.string().nullable(),
|
|
173
|
+
stopped: z.string().nullable(),
|
|
174
|
+
updated_at: z.string(),
|
|
175
|
+
// Added in 1.1.0; optional so that earlier records still load.
|
|
176
|
+
/**
|
|
177
|
+
* Set while final archives the server has not accepted yet are in the
|
|
178
|
+
* chain: the chain as it was before the first of them, whose baseline is
|
|
179
|
+
* kept. A server that refuses final archives sends the chain back to it
|
|
180
|
+
* (rewindRefusedFinal).
|
|
181
|
+
*/
|
|
182
|
+
rewind: chainPointSchema.nullable().optional(),
|
|
183
|
+
/** A turn was captured since the last final archive: at app start the folder is checked once more. */
|
|
184
|
+
final_due: z.boolean().optional(),
|
|
185
|
+
/** When the session was deleted in the app: no final archive at app start. */
|
|
186
|
+
ended: z.string().nullable().optional(),
|
|
187
|
+
/** A touched-files chain without its base yet: the completed turns last seen, the turn of a base a final archive captures. */
|
|
188
|
+
turn_seen: z.number().int().nonnegative().optional(),
|
|
189
|
+
/**
|
|
190
|
+
* When the session was last active: its base, a prompt (captureBase once
|
|
191
|
+
* per app run) or a turn end (captureDelta). Never a final archive, a
|
|
192
|
+
* rewind or other bookkeeping, which move updated_at: the app-start window
|
|
193
|
+
* counts from this. A record without it (1.0.10) counts from its
|
|
194
|
+
* updated_at, and keeps that time from then on.
|
|
195
|
+
*/
|
|
196
|
+
last_activity_at: z.string().optional(),
|
|
197
|
+
}).transform((state) => ({ ...state, last_activity_at: state.last_activity_at ?? state.updated_at }));
|
|
198
|
+
type SessionState = z.infer<typeof sessionStateSchema>;
|
|
199
|
+
|
|
200
|
+
const createRequestSchema = z.object({
|
|
201
|
+
archive_id: z.string().regex(UUID_PATTERN),
|
|
202
|
+
session_id: z.string().regex(SESSION_ID_PATTERN),
|
|
203
|
+
kind: z.enum(["base", "delta"]),
|
|
204
|
+
sequence: z.number().int().nonnegative(),
|
|
205
|
+
turn: z.number().int().nonnegative(),
|
|
206
|
+
parent_archive_id: z.string().regex(UUID_PATTERN).nullable(),
|
|
207
|
+
size: z.number().int().positive(),
|
|
208
|
+
sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
209
|
+
kid: z.string().regex(/^[0-9a-f]{16}$/),
|
|
210
|
+
content: z.literal(SEAL_CONTENT),
|
|
211
|
+
marker: z.string(),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const queueRecordSchema = z.object({
|
|
215
|
+
v: z.literal(1),
|
|
216
|
+
archive_id: z.string().regex(UUID_PATTERN),
|
|
217
|
+
session_key: z.string(),
|
|
218
|
+
request: createRequestSchema,
|
|
219
|
+
sealed_file: z.string(),
|
|
220
|
+
/** A delta's trigger (1.1.0 on): how a refused final is told from a broken chain. */
|
|
221
|
+
trigger: z.enum(["turn", "final"]).optional(),
|
|
222
|
+
created_at: z.string(),
|
|
223
|
+
attempts: z.number().int().nonnegative(),
|
|
224
|
+
next_attempt_at: z.string().nullable(),
|
|
225
|
+
recreates: z.number().int().nonnegative(),
|
|
226
|
+
upload: z.object({
|
|
227
|
+
upload_id: z.string(),
|
|
228
|
+
object_key: z.string(),
|
|
229
|
+
part_size: z.number().int().positive(),
|
|
230
|
+
part_count: z.number().int().positive(),
|
|
231
|
+
etags: z.record(z.string(), z.string()),
|
|
232
|
+
}).nullable(),
|
|
233
|
+
});
|
|
234
|
+
type QueueRecord = z.infer<typeof queueRecordSchema>;
|
|
235
|
+
|
|
236
|
+
const archiverStateSchema = z.object({
|
|
237
|
+
v: z.literal(1),
|
|
238
|
+
disabled: z.string().nullable(),
|
|
239
|
+
key: z.object({ kid: z.string(), public_key: z.string(), alg: z.string() }).nullable(),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
async function readText(path: string): Promise<string | null> {
|
|
243
|
+
try {
|
|
244
|
+
return await readFile(path, "utf8");
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function errorSummary(error: unknown): string {
|
|
251
|
+
if (error instanceof Error) return `${error.name}: ${error.message}`;
|
|
252
|
+
return "unknown error";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Whether `policy` lets a chain with this marker capture: a plain folder needs all_folders, touched files touched_files, git nothing. */
|
|
256
|
+
function markerAllowed(marker: string, policy: ArchivePolicy): boolean {
|
|
257
|
+
if (marker === FOLDER_MARKER) return policy.allFolders;
|
|
258
|
+
if (marker === TOUCHED_MARKER) return policy.touchedFiles;
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** How a capture was asked for. */
|
|
263
|
+
type CaptureOptions = {
|
|
264
|
+
/** Deltas: a completed turn, or a final archive with its reason (a touched-files base a final archive captures says "final" too, without it in its manifest). */
|
|
265
|
+
trigger?: ArchiveTrigger;
|
|
266
|
+
reason?: FinalReason;
|
|
267
|
+
/** A final archive of a session deleted in the app: its record is marked ended. */
|
|
268
|
+
ended?: boolean;
|
|
269
|
+
/** Cancels the capture until its commit. */
|
|
270
|
+
signal?: AbortSignal;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export class SessionArchiver {
|
|
274
|
+
private readonly dir: string;
|
|
275
|
+
private readonly dirs: { sessions: string; baselines: string; hashCache: string; queue: string; pending: string; tmp: string; touched: string };
|
|
276
|
+
private readonly uploader: ArchiveUploader;
|
|
277
|
+
private readonly detectors: readonly ProjectMarkerDetector[];
|
|
278
|
+
private readonly folderGate: FolderGateOptions | undefined;
|
|
279
|
+
private readonly appDirs: string[];
|
|
280
|
+
private readonly includeCredentials: boolean;
|
|
281
|
+
private readonly log: ArchiveLog;
|
|
282
|
+
private readonly now: () => Date;
|
|
283
|
+
private readonly random: { uuid(): string; bytes(n: number): Buffer };
|
|
284
|
+
private readonly retry: RetryPolicy;
|
|
285
|
+
private started: Promise<void> | null = null;
|
|
286
|
+
private key: ArchiveKey | null = null;
|
|
287
|
+
private disabled: string | null = null;
|
|
288
|
+
/** The last folder policy answer, kept with the key for POLICY_TTL_MS; a failed probe is kept as off. */
|
|
289
|
+
private policy: { value: ArchivePolicy; at: number } | null = null;
|
|
290
|
+
/** The probe in flight, shared by the sessions that start meanwhile. */
|
|
291
|
+
private policyProbe: Promise<KeyResult> | null = null;
|
|
292
|
+
private readonly sessionTails = new Map<string, Promise<void>>();
|
|
293
|
+
/** Sessions stopped by the drain; captures in flight check it before they commit. */
|
|
294
|
+
private readonly stoppedSessions = new Map<string, string>();
|
|
295
|
+
/** Sessions whose base is being reset after a key change; the drain holds their jobs. */
|
|
296
|
+
private readonly resettingSessions = new Set<string>();
|
|
297
|
+
/** Bumped by signOut(): captures and drains started before it never commit. */
|
|
298
|
+
private generation = 0;
|
|
299
|
+
private draining: Promise<DrainResult> | null = null;
|
|
300
|
+
private drainAgain = false;
|
|
301
|
+
private drainController: AbortController | null = null;
|
|
302
|
+
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
303
|
+
/** The server accepted a final archive in this app run: a later refusal is a real chain conflict. */
|
|
304
|
+
private finalsAccepted = false;
|
|
305
|
+
/** The server refused a final archive (a backend without them): none is captured until the app restarts. */
|
|
306
|
+
private finalsRefused = false;
|
|
307
|
+
/** The paths each touched-files session touched (touched.ts). */
|
|
308
|
+
private readonly touched: TouchedPathStore;
|
|
309
|
+
|
|
310
|
+
constructor(options: SessionArchiverOptions) {
|
|
311
|
+
const stateDir = resolve(options.stateDir);
|
|
312
|
+
this.dir = join(stateDir, ARCHIVE_STATE_DIRECTORY);
|
|
313
|
+
this.dirs = {
|
|
314
|
+
sessions: join(this.dir, "sessions"),
|
|
315
|
+
baselines: join(this.dir, "baselines"),
|
|
316
|
+
hashCache: join(this.dir, "hash-cache"),
|
|
317
|
+
queue: join(this.dir, "queue"),
|
|
318
|
+
pending: join(this.dir, "pending"),
|
|
319
|
+
tmp: join(this.dir, "tmp"),
|
|
320
|
+
touched: join(this.dir, "touched"),
|
|
321
|
+
};
|
|
322
|
+
this.log = options.log ?? (() => undefined);
|
|
323
|
+
this.now = options.now ?? (() => new Date());
|
|
324
|
+
this.random = options.random ?? { uuid: () => randomUUID(), bytes: (n) => randomBytes(n) };
|
|
325
|
+
this.retry = {
|
|
326
|
+
...DEFAULT_RETRY_POLICY,
|
|
327
|
+
random: () => this.random.bytes(4).readUInt32BE(0) / 2 ** 32,
|
|
328
|
+
...options.retry,
|
|
329
|
+
};
|
|
330
|
+
this.uploader = new ArchiveUploader({
|
|
331
|
+
gatewayUrl: options.gatewayUrl ?? process.env.OMNIRUSH_GATEWAY_URL,
|
|
332
|
+
...(options.accessToken !== undefined ? { accessToken: options.accessToken } : {}),
|
|
333
|
+
...(options.refreshAccessToken ? { refreshAccessToken: options.refreshAccessToken } : {}),
|
|
334
|
+
fetch: options.fetch ?? externalFetch,
|
|
335
|
+
...(options.request ? { request: options.request } : {}),
|
|
336
|
+
retry: this.retry,
|
|
337
|
+
log: this.log,
|
|
338
|
+
});
|
|
339
|
+
this.detectors = options.detectors ?? defaultProjectDetectors;
|
|
340
|
+
this.folderGate = options.folderGate;
|
|
341
|
+
this.appDirs = [stateDir, ...(options.excludedDirs ?? []).map((dir) => resolve(dir))];
|
|
342
|
+
this.includeCredentials = options.archiveIncludeCredentialFiles === true;
|
|
343
|
+
this.touched = new TouchedPathStore(this.dirs.touched, {
|
|
344
|
+
ready: () => this.start(),
|
|
345
|
+
modeOf: async (sessionId) => {
|
|
346
|
+
const state = await this.loadSession(sessionId);
|
|
347
|
+
if (!state) return "unknown";
|
|
348
|
+
return state.marker === TOUCHED_MARKER && !state.stopped && !state.ended ? "tracked" : "ignored";
|
|
349
|
+
},
|
|
350
|
+
log: this.log,
|
|
351
|
+
...(options.touchedFlushMs !== undefined ? { flushMs: options.touchedFlushMs } : {}),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// --- public API -------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
/** Crash recovery (section 13.4). Idempotent; every other method awaits it. */
|
|
358
|
+
start(): Promise<void> {
|
|
359
|
+
this.started ??= this.recover().catch((error) => {
|
|
360
|
+
this.log("warn", "OmniRush archive state recovery failed", { error: errorSummary(error) });
|
|
361
|
+
});
|
|
362
|
+
return this.started;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The device bearer changed (collector token rotation). */
|
|
366
|
+
setAccessToken(token: string | null): void {
|
|
367
|
+
this.uploader.setAccessToken(token);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Base archive at session start (new session, or a resumed one without a
|
|
372
|
+
* base). No-op when the gate says not archivable, archiving is off, or the
|
|
373
|
+
* session already has a base (its record then only notes the prompt as
|
|
374
|
+
* activity, for the app-start window). Checks consent through GET
|
|
375
|
+
* /archives/key before packing anything. A folder without `.git` counts as
|
|
376
|
+
* a project only while the all-folders or the touched-files policy is on
|
|
377
|
+
* (folderPolicy); with both off nothing is written and only the policy is
|
|
378
|
+
* read. A touched-files session is only registered here ("unchanged"): its
|
|
379
|
+
* base comes with the first delta or final archive that has a touched file.
|
|
380
|
+
* One registered before (resumed) runs no gate again; its record notes the
|
|
381
|
+
* prompt as activity too.
|
|
382
|
+
*/
|
|
383
|
+
captureBase(sessionId: string, root: string, turn = 0): Promise<CaptureResult> {
|
|
384
|
+
return this.guard("base", sessionId, turn, async () => this.withSession(sessionId, async () => {
|
|
385
|
+
const generation = this.generation;
|
|
386
|
+
const state = await this.loadSession(sessionId);
|
|
387
|
+
if (state?.stopped || this.stoppedSessions.has(sessionId)) return { status: "skipped", reason: "stopped" };
|
|
388
|
+
if (state && state.next_sequence > 0) {
|
|
389
|
+
// A prompt on a session archived in an earlier app run: it is active again.
|
|
390
|
+
if (generation === this.generation) await this.saveSession({ ...state, last_activity_at: this.now().toISOString() });
|
|
391
|
+
return { status: "skipped", reason: "exists" };
|
|
392
|
+
}
|
|
393
|
+
// A touched-files chain registered earlier (a resumed session) still waits for its base. Like a
|
|
394
|
+
// chain with one, it keeps its paths whatever the policy answers now: its deltas and finals ask.
|
|
395
|
+
// The prompt counts as its activity too.
|
|
396
|
+
if (state?.marker === TOUCHED_MARKER) {
|
|
397
|
+
if (!state.ended) this.touched.track(sessionId);
|
|
398
|
+
if (generation === this.generation) await this.saveSession({ ...state, turn_seen: turn, updated_at: this.now().toISOString(), last_activity_at: this.now().toISOString() });
|
|
399
|
+
return { status: "skipped", reason: "unchanged" };
|
|
400
|
+
}
|
|
401
|
+
return this.captureBaseLocked(sessionId, resolve(root), turn, generation, true);
|
|
402
|
+
}));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Delta after a completed turn. No-op without a base, when the session
|
|
407
|
+
* stopped archiving, when nothing in the folder changed, or for a plain
|
|
408
|
+
* folder (marker `folder`) or touched files (marker `touched`) while their
|
|
409
|
+
* policy is off. A session whose base could not be captured yet (key
|
|
410
|
+
* unavailable, or no touched file yet) gets its base instead. A null turn
|
|
411
|
+
* (the engine's messages could not be read) follows the last archived
|
|
412
|
+
* turn. Whatever it captures, the turn counts as the session's activity
|
|
413
|
+
* (the app-start window).
|
|
414
|
+
*/
|
|
415
|
+
captureDelta(sessionId: string, root: string, turn: number | null): Promise<CaptureResult> {
|
|
416
|
+
return this.guard("delta", sessionId, turn ?? 0, async () => this.withSession(sessionId, async () => {
|
|
417
|
+
const generation = this.generation;
|
|
418
|
+
if (this.disabled) return { status: "skipped", reason: "disabled" };
|
|
419
|
+
const loaded = await this.loadSession(sessionId);
|
|
420
|
+
if (!loaded) return { status: "skipped", reason: "no_base" };
|
|
421
|
+
if (loaded.stopped || this.stoppedSessions.has(sessionId)) return { status: "skipped", reason: "stopped" };
|
|
422
|
+
// A turn ended: the session was active, whatever this capture does.
|
|
423
|
+
const state: SessionState = { ...loaded, last_activity_at: this.now().toISOString() };
|
|
424
|
+
if (generation === this.generation) await this.saveSession(state);
|
|
425
|
+
if (resolve(root) !== state.root) this.log("warn", "OmniRush archive delta root differs from the session root; using the session root", { sessionId });
|
|
426
|
+
const touched = state.marker === TOUCHED_MARKER;
|
|
427
|
+
if (state.next_sequence === 0 && !touched) return this.captureBaseLocked(sessionId, state.root, turn ?? 0, generation);
|
|
428
|
+
const next = state.next_sequence === 0 ? turn ?? state.turn_seen ?? 0 : turn ?? (state.last_turn ?? 0) + 1;
|
|
429
|
+
if (state.last_turn !== null && next <= state.last_turn) return { status: "skipped", reason: "stale_turn" };
|
|
430
|
+
// A plain folder's or touched files' archives pause while their policy is off; the next one after it is on again catches up.
|
|
431
|
+
if ((state.marker === FOLDER_MARKER || touched) && !(await this.policyAllows(state.marker, generation))) return { status: "skipped", reason: "not_archivable" };
|
|
432
|
+
const key = await this.currentKey();
|
|
433
|
+
if (key === "disabled") return { status: "skipped", reason: "disabled" };
|
|
434
|
+
if (key === "unavailable") return { status: "skipped", reason: "unavailable" };
|
|
435
|
+
// A touched-files session without a base: its base, with this turn, once a touched file is there.
|
|
436
|
+
if (state.next_sequence === 0) return this.captureArchive(state, "base", next, key, generation);
|
|
437
|
+
return this.captureArchive(state, "delta", next, key, generation, { trigger: "turn" });
|
|
438
|
+
}));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* A final archive: the folder after the last completed turn (the session
|
|
443
|
+
* went quiet, a turn ended without completing, the session was deleted,
|
|
444
|
+
* the app quits or starts again). A delta like any other, numbered with
|
|
445
|
+
* the last archived turn again, whose manifest says `"trigger": "final"`
|
|
446
|
+
* and why. Only for a session with a base, whose root the gate still
|
|
447
|
+
* accepts (a plain folder or touched files: the folder refusals, and their
|
|
448
|
+
* policy is on); nothing when the folder did not change. A touched-files
|
|
449
|
+
* session without a base gets its base here once it has a touched file,
|
|
450
|
+
* numbered with the completed turns last seen. `signal` cancels it until
|
|
451
|
+
* its commit.
|
|
452
|
+
*/
|
|
453
|
+
captureFinal(sessionId: string, reason: FinalReason, options: { signal?: AbortSignal } = {}): Promise<CaptureResult> {
|
|
454
|
+
const { signal } = options;
|
|
455
|
+
const ended = reason === "session_deleted";
|
|
456
|
+
return this.guard("delta", sessionId, 0, async () => this.withSession(sessionId, async () => {
|
|
457
|
+
const result = await this.captureFinalLocked(sessionId, reason, ended, signal);
|
|
458
|
+
if (ended) {
|
|
459
|
+
// A deleted session keeps no touched paths once its record says so (or there is none).
|
|
460
|
+
const after = await this.loadSession(sessionId);
|
|
461
|
+
if (!after || after.ended) await this.touched.forget(sessionId);
|
|
462
|
+
}
|
|
463
|
+
return result;
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private async captureFinalLocked(sessionId: string, reason: FinalReason, ended: boolean, signal?: AbortSignal): Promise<CaptureResult> {
|
|
468
|
+
const generation = this.generation;
|
|
469
|
+
if (signal?.aborted) return { status: "skipped", reason: "cancelled" };
|
|
470
|
+
if (this.disabled || !this.uploader.configured) return { status: "skipped", reason: "disabled" };
|
|
471
|
+
const state = await this.loadSession(sessionId);
|
|
472
|
+
const unbased = state?.next_sequence === 0 && state.marker === TOUCHED_MARKER;
|
|
473
|
+
const turn = unbased ? state.turn_seen ?? 0 : state && state.next_sequence > 0 ? state.last_turn : null;
|
|
474
|
+
if (!state || turn === null) return { status: "skipped", reason: "no_base" };
|
|
475
|
+
if (state.stopped || this.stoppedSessions.has(sessionId)) return { status: "skipped", reason: "stopped" };
|
|
476
|
+
const skip = async (skipReason: CaptureSkipReason): Promise<CaptureResult> => {
|
|
477
|
+
if (ended && generation === this.generation) await this.saveSession({ ...state, final_due: false, ended: this.now().toISOString(), updated_at: this.now().toISOString() });
|
|
478
|
+
return { status: "skipped", reason: skipReason };
|
|
479
|
+
};
|
|
480
|
+
// A base repeats no turn: a backend without final archives takes it.
|
|
481
|
+
if (this.finalsRefused && !unbased) return skip("unsupported");
|
|
482
|
+
// The folder may be gone, or no longer what the start-time gate accepted.
|
|
483
|
+
if (!(await this.chainAllowed(state, generation))) return skip("not_archivable");
|
|
484
|
+
const key = await this.currentKey(signal);
|
|
485
|
+
if (signal?.aborted) return { status: "skipped", reason: "cancelled" };
|
|
486
|
+
if (key === "disabled") return { status: "skipped", reason: "disabled" };
|
|
487
|
+
if (key === "unavailable") return { status: "skipped", reason: "unavailable" };
|
|
488
|
+
try {
|
|
489
|
+
return await this.captureArchive(state, unbased ? "base" : "delta", turn, key, generation, { trigger: "final", reason, ended, ...(signal ? { signal } : {}) });
|
|
490
|
+
} catch (error) {
|
|
491
|
+
if (signal?.aborted) return { status: "skipped", reason: "cancelled" };
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* A path the session touched, workspace-relative (portable `/`), as the
|
|
498
|
+
* collector reports it: a tool's path in the trace, or a change the
|
|
499
|
+
* watcher saw. Kept (on disk, a moment later) for a touched-files session;
|
|
500
|
+
* dropped for any other once the gate has run. Cheap: called for every
|
|
501
|
+
* file event.
|
|
502
|
+
*/
|
|
503
|
+
recordTouched(sessionId: string, path: string): void {
|
|
504
|
+
if (this.disabled || !SESSION_ID_PATTERN.test(sessionId) || this.stoppedSessions.has(sessionId)) return;
|
|
505
|
+
this.touched.note(sessionId, path);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** The session is not archived (a child session): its reported paths go. */
|
|
509
|
+
forgetTouched(sessionId: string): void {
|
|
510
|
+
void this.touched.forget(sessionId).catch((error: unknown) => this.log("warn", "OmniRush touched-files paths could not be removed", { sessionId, error: errorSummary(error) }));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The sessions to give a final archive once at app start: every session
|
|
515
|
+
* with a turn captured since its last final archive (the app quit before
|
|
516
|
+
* that final, or crashed), the most recently active session on each
|
|
517
|
+
* other folder (the folder may have changed while the app was closed),
|
|
518
|
+
* and every touched-files session (its files are its own). Only sessions
|
|
519
|
+
* active (a base, a prompt, a turn) within the last 7 days, not stopped
|
|
520
|
+
* and not deleted, with a base or (touched files) a touched path: a final
|
|
521
|
+
* archive never extends the window, so a chat left alone gets none a week
|
|
522
|
+
* after its last turn, however often the app starts. The most recently
|
|
523
|
+
* active first, at most 10.
|
|
524
|
+
*/
|
|
525
|
+
async startFinalCandidates(): Promise<string[]> {
|
|
526
|
+
try {
|
|
527
|
+
await this.start();
|
|
528
|
+
if (this.disabled) return [];
|
|
529
|
+
const nowMs = this.now().getTime();
|
|
530
|
+
const sessions: SessionState[] = [];
|
|
531
|
+
for (const name of await readdir(this.dirs.sessions)) {
|
|
532
|
+
if (!name.endsWith(".json")) continue;
|
|
533
|
+
const parsed = sessionStateSchema.safeParse(await readJsonFile(join(this.dirs.sessions, name)));
|
|
534
|
+
if (!parsed.success || name !== `${stateKey(parsed.data.session_id)}.json`) continue;
|
|
535
|
+
const state = parsed.data;
|
|
536
|
+
if (state.stopped || state.ended || !(nowMs - Date.parse(state.last_activity_at) <= START_FINAL_WINDOW_MS)) continue;
|
|
537
|
+
// Without a base, only a touched-files session that touched something may get one.
|
|
538
|
+
if (state.next_sequence === 0 && !(state.marker === TOUCHED_MARKER && (await this.touched.has(state.session_id)))) continue;
|
|
539
|
+
sessions.push(state);
|
|
540
|
+
}
|
|
541
|
+
sessions.sort((left, right) => right.last_activity_at.localeCompare(left.last_activity_at));
|
|
542
|
+
const roots = new Set<string>();
|
|
543
|
+
const picked: string[] = [];
|
|
544
|
+
for (const state of sessions) {
|
|
545
|
+
// A touched-files chain holds only its own session's files: it counts as a folder of its own.
|
|
546
|
+
const folder = state.marker === TOUCHED_MARKER ? `${state.root}\0${state.session_id}` : state.root;
|
|
547
|
+
const newestOnRoot = !roots.has(folder);
|
|
548
|
+
roots.add(folder);
|
|
549
|
+
if (state.final_due || newestOnRoot) picked.push(state.session_id);
|
|
550
|
+
}
|
|
551
|
+
return picked.slice(0, MAX_START_FINALS);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
this.log("warn", "OmniRush archive could not list the sessions to check at start", { error: errorSummary(error) });
|
|
554
|
+
return [];
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Uploads queued archives until the queue is empty or blocked; never throws. */
|
|
559
|
+
drain(): Promise<DrainResult> {
|
|
560
|
+
if (this.draining) {
|
|
561
|
+
this.drainAgain = true;
|
|
562
|
+
return this.draining;
|
|
563
|
+
}
|
|
564
|
+
const run = this.runDrain().finally(() => {
|
|
565
|
+
if (this.draining === run) this.draining = null;
|
|
566
|
+
});
|
|
567
|
+
this.draining = run;
|
|
568
|
+
return run;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Queue size, for diagnostics. */
|
|
572
|
+
async pendingStatus(): Promise<{ jobs: number; bytes: number; disabled: string | null }> {
|
|
573
|
+
await this.start();
|
|
574
|
+
const records = await this.listQueue();
|
|
575
|
+
return { jobs: records.length, bytes: records.reduce((sum, record) => sum + record.request.size, 0), disabled: this.disabled };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The account signed out: abort in-flight uploads (best effort), drop every
|
|
580
|
+
* queued archive, baseline, hash cache and session record. Archives are the
|
|
581
|
+
* user's; nothing may be uploaded later under another account.
|
|
582
|
+
*/
|
|
583
|
+
async signOut(): Promise<void> {
|
|
584
|
+
this.generation += 1;
|
|
585
|
+
this.touched.clear();
|
|
586
|
+
this.clearRetryTimer();
|
|
587
|
+
this.drainController?.abort();
|
|
588
|
+
try {
|
|
589
|
+
// Without a state dir there is nothing to abort or delete; recovery would only create one.
|
|
590
|
+
if (await stat(this.dir).then(() => true, () => false)) {
|
|
591
|
+
await this.start();
|
|
592
|
+
const records = await this.listQueue();
|
|
593
|
+
const aborts = records.filter((record) => record.upload).map((record) => this.uploader.abort(record.archive_id, AbortSignal.timeout(SIGN_OUT_ABORT_TIMEOUT_MS)));
|
|
594
|
+
await Promise.race([Promise.all(aborts), new Promise((resolvePromise) => setTimeout(resolvePromise, SIGN_OUT_ABORT_TIMEOUT_MS).unref?.())]);
|
|
595
|
+
}
|
|
596
|
+
await this.draining?.catch(() => undefined);
|
|
597
|
+
await rm(this.dir, { recursive: true, force: true });
|
|
598
|
+
} catch (error) {
|
|
599
|
+
this.log("warn", "OmniRush archive sign-out cleanup failed", { error: errorSummary(error) });
|
|
600
|
+
}
|
|
601
|
+
this.key = null;
|
|
602
|
+
this.disabled = null;
|
|
603
|
+
this.policy = null;
|
|
604
|
+
this.policyProbe = null;
|
|
605
|
+
this.stoppedSessions.clear();
|
|
606
|
+
this.resettingSessions.clear();
|
|
607
|
+
this.finalsAccepted = false;
|
|
608
|
+
this.finalsRefused = false;
|
|
609
|
+
this.uploader.setAccessToken(null);
|
|
610
|
+
this.started = null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* App shutdown: stops the drain and the retry timer at once; queued
|
|
615
|
+
* archives stay for the next start. With `finals`, a final archive of each
|
|
616
|
+
* of those sessions is captured first, one after the other, all within
|
|
617
|
+
* `budgetMs` (a session whose capture is still running goes last); captures
|
|
618
|
+
* already running may commit within the budget too. After it nothing
|
|
619
|
+
* commits: a capture still packing is dropped (the server that replaces
|
|
620
|
+
* this one after a sign-out must not find it queued), and the next start
|
|
621
|
+
* checks the folder again (startFinalCandidates).
|
|
622
|
+
*/
|
|
623
|
+
async stop(options: { finals?: readonly string[]; budgetMs?: number } = {}): Promise<void> {
|
|
624
|
+
this.clearRetryTimer();
|
|
625
|
+
this.drainController?.abort();
|
|
626
|
+
// Touched paths reported in the last moments reach disk for the next start.
|
|
627
|
+
await this.touched.flush().catch((error: unknown) => this.log("warn", "OmniRush touched-files paths could not be written", { error: errorSummary(error) }));
|
|
628
|
+
const budgetMs = options.budgetMs ?? 0;
|
|
629
|
+
if (options.finals && options.finals.length > 0 && budgetMs > 0) await this.quitFinals(options.finals, budgetMs);
|
|
630
|
+
this.generation += 1;
|
|
631
|
+
this.clearRetryTimer();
|
|
632
|
+
this.drainController?.abort();
|
|
633
|
+
await this.draining?.catch(() => undefined);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** stop()'s final archives: sessions with nothing running first; whatever is left at the deadline is cancelled. */
|
|
637
|
+
private async quitFinals(sessionIds: readonly string[], budgetMs: number): Promise<void> {
|
|
638
|
+
const controller = new AbortController();
|
|
639
|
+
const ordered = [...sessionIds.filter((id) => !this.sessionTails.has(id)), ...sessionIds.filter((id) => this.sessionTails.has(id))];
|
|
640
|
+
let done = 0;
|
|
641
|
+
const captures = (async () => {
|
|
642
|
+
for (const sessionId of ordered) {
|
|
643
|
+
if (controller.signal.aborted) return;
|
|
644
|
+
await this.captureFinal(sessionId, "app_quit", { signal: controller.signal });
|
|
645
|
+
done += 1;
|
|
646
|
+
}
|
|
647
|
+
})();
|
|
648
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
649
|
+
const deadline = new Promise<void>((resolvePromise) => {
|
|
650
|
+
timer = setTimeout(resolvePromise, budgetMs);
|
|
651
|
+
timer.unref?.();
|
|
652
|
+
});
|
|
653
|
+
await Promise.race([captures, deadline]);
|
|
654
|
+
clearTimeout(timer);
|
|
655
|
+
controller.abort();
|
|
656
|
+
if (done < ordered.length) {
|
|
657
|
+
this.log("info", "OmniRush archive shutdown budget ran out; the remaining folders are checked at the next start", { budgetMs, sessions: ordered.length, finished: done });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// --- captures -------------------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
private async guard(kind: ArchiveKind, sessionId: string, turn: number, task: () => Promise<CaptureResult>): Promise<CaptureResult> {
|
|
664
|
+
if (!SESSION_ID_PATTERN.test(sessionId) || !Number.isSafeInteger(turn) || turn < 0 || turn > MAX_TURN) {
|
|
665
|
+
this.log("warn", "OmniRush archive capture called with an invalid session id or turn", { kind });
|
|
666
|
+
return { status: "skipped", reason: "failed" };
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
await this.start();
|
|
670
|
+
return await task();
|
|
671
|
+
} catch (error) {
|
|
672
|
+
this.log("warn", "OmniRush archive capture failed", { sessionId, kind, error: errorSummary(error) });
|
|
673
|
+
return { status: "skipped", reason: "failed" };
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private withSession<T>(sessionId: string, task: () => Promise<T>): Promise<T> {
|
|
678
|
+
const previous = this.sessionTails.get(sessionId) ?? Promise.resolve();
|
|
679
|
+
const run = previous.then(task);
|
|
680
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
681
|
+
this.sessionTails.set(sessionId, tail);
|
|
682
|
+
void tail.then(() => {
|
|
683
|
+
if (this.sessionTails.get(sessionId) === tail) this.sessionTails.delete(sessionId);
|
|
684
|
+
});
|
|
685
|
+
return run;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* The gate, then the base: a whole-folder base at once; a touched-files
|
|
690
|
+
* session is registered and gets its base from the first capture that has
|
|
691
|
+
* a touched file (at session start, `lazy`, only registered).
|
|
692
|
+
*/
|
|
693
|
+
private async captureBaseLocked(sessionId: string, root: string, turn: number, generation: number, lazy = false): Promise<CaptureResult> {
|
|
694
|
+
if (!this.uploader.configured) return { status: "skipped", reason: "disabled" };
|
|
695
|
+
// A root no git detector qualified, and that no folder refusal stops, asks for the folder policy (4.4).
|
|
696
|
+
const probe: { fetched: KeyResult | null } = { fetched: null };
|
|
697
|
+
const gate = await isArchivableProject(root, [...this.detectors, this.folderDetector(generation, probe)], { appDirs: this.appDirs });
|
|
698
|
+
const touched = gate.marker === TOUCHED_MARKER;
|
|
699
|
+
// Whatever else the gate said, the session keeps no touched paths.
|
|
700
|
+
if (!touched) await this.touched.forget(sessionId);
|
|
701
|
+
if (!gate.archivable || !gate.marker) return { status: "skipped", reason: "not_archivable" };
|
|
702
|
+
// The key and the consent check (7.2): a probe made for this base already holds them; else the full fetch.
|
|
703
|
+
const fetched = probe.fetched ?? await this.uploader.fetchKey();
|
|
704
|
+
if (!probe.fetched) this.rememberPolicy(fetched, generation, false);
|
|
705
|
+
if (fetched.status === "disabled") {
|
|
706
|
+
await this.disable(fetched.code);
|
|
707
|
+
return { status: "skipped", reason: "disabled" };
|
|
708
|
+
}
|
|
709
|
+
// The policy was turned off since the answer this folder was let in on.
|
|
710
|
+
if (fetched.status === "ok" && !markerAllowed(gate.marker, fetched.policy)) {
|
|
711
|
+
if (touched) await this.touched.forget(sessionId);
|
|
712
|
+
return { status: "skipped", reason: "not_archivable" };
|
|
713
|
+
}
|
|
714
|
+
const state: SessionState = {
|
|
715
|
+
v: 1,
|
|
716
|
+
session_id: sessionId,
|
|
717
|
+
root,
|
|
718
|
+
marker: gate.marker,
|
|
719
|
+
next_sequence: 0,
|
|
720
|
+
last_archive_id: null,
|
|
721
|
+
last_turn: null,
|
|
722
|
+
baseline: null,
|
|
723
|
+
stopped: null,
|
|
724
|
+
updated_at: this.now().toISOString(),
|
|
725
|
+
...(touched ? { turn_seen: turn } : {}),
|
|
726
|
+
last_activity_at: this.now().toISOString(),
|
|
727
|
+
};
|
|
728
|
+
if (touched) this.touched.track(sessionId);
|
|
729
|
+
if (fetched.status === "unavailable") {
|
|
730
|
+
// Remembered with next_sequence 0: the next captureDelta tries the base again (a folder passes the gate and the policy again).
|
|
731
|
+
if (generation === this.generation) await this.saveSession(state);
|
|
732
|
+
this.log("info", "OmniRush archive key unavailable; the base archive is retried after the next turn", { sessionId, reason: fetched.reason });
|
|
733
|
+
return { status: "skipped", reason: "unavailable" };
|
|
734
|
+
}
|
|
735
|
+
await this.enable(fetched.key);
|
|
736
|
+
if (touched && lazy) {
|
|
737
|
+
if (generation === this.generation) await this.saveSession(state);
|
|
738
|
+
return { status: "skipped", reason: "unchanged" };
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
return await this.captureArchive(state, "base", turn, fetched.key, generation);
|
|
742
|
+
} catch (error) {
|
|
743
|
+
// Remembered with next_sequence 0 so the next captureDelta tries the base again.
|
|
744
|
+
if (generation === this.generation && !(await this.loadSession(sessionId))) await this.saveSession(state).catch(() => undefined);
|
|
745
|
+
throw error;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** The folder detector (4.4) with its refusals and the policy; `probe` receives the key response of a probe it made. */
|
|
750
|
+
private folderDetector(generation: number, probe?: { fetched: KeyResult | null }): ProjectMarkerDetector {
|
|
751
|
+
return folderDetector(async () => {
|
|
752
|
+
const answer = await this.folderPolicy(generation);
|
|
753
|
+
if (probe) probe.fetched = answer.fetched;
|
|
754
|
+
return answer.policy;
|
|
755
|
+
}, this.folderGate);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* The folder policy (4.4: all folders, touched files) for a folder the
|
|
760
|
+
* gate would otherwise let in: the answer kept with the key while it is
|
|
761
|
+
* younger than POLICY_TTL_MS, else one probe (a single GET /archives/key,
|
|
762
|
+
* no backoff, no bearer refresh). Anything but a valid key with the flag
|
|
763
|
+
* `true` is off; a failed probe is kept as off too, so an outage costs one
|
|
764
|
+
* request per POLICY_TTL_MS, never a retry storm. `fetched` is the probe's
|
|
765
|
+
* answer when this call made one: the base reuses it for its key.
|
|
766
|
+
*/
|
|
767
|
+
private async folderPolicy(generation: number): Promise<{ policy: ArchivePolicy; fetched: KeyResult | null }> {
|
|
768
|
+
const kept = this.policy;
|
|
769
|
+
if (kept && this.now().getTime() - kept.at < POLICY_TTL_MS) return { policy: kept.value, fetched: null };
|
|
770
|
+
let probe = this.policyProbe;
|
|
771
|
+
if (!probe) {
|
|
772
|
+
const started = this.uploader.probeKey();
|
|
773
|
+
probe = started;
|
|
774
|
+
this.policyProbe = started;
|
|
775
|
+
void started.finally(() => {
|
|
776
|
+
if (this.policyProbe === started) this.policyProbe = null;
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
const fetched = await probe;
|
|
780
|
+
this.rememberPolicy(fetched, generation, true);
|
|
781
|
+
return { policy: fetched.status === "ok" ? fetched.policy : POLICY_OFF, fetched };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Whether the policy a plain folder's (`folder`) or touched files' (`touched`) chain needs is on. */
|
|
785
|
+
private async policyAllows(marker: string, generation: number): Promise<boolean> {
|
|
786
|
+
return markerAllowed(marker, (await this.folderPolicy(generation)).policy);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Whether a chain may still capture (a final archive): a git root still
|
|
791
|
+
* passes the gate; a plain folder's or touched files' root still passes
|
|
792
|
+
* the root checks and the folder refusals, and their policy is on.
|
|
793
|
+
*/
|
|
794
|
+
private async chainAllowed(state: SessionState, generation: number): Promise<boolean> {
|
|
795
|
+
if (state.marker !== FOLDER_MARKER && state.marker !== TOUCHED_MARKER) return (await isArchivableProject(state.root, this.detectors, { appDirs: this.appDirs })).archivable;
|
|
796
|
+
const accepted: ProjectMarkerDetector = async (root) => ((await folderRootRefusal(root, this.folderGate)) ? null : { archivable: true, reason: state.marker, marker: state.marker });
|
|
797
|
+
if (!(await isArchivableProject(state.root, [accepted], { appDirs: this.appDirs })).archivable) return false;
|
|
798
|
+
return this.policyAllows(state.marker, generation);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** 422 archive_marker_not_allowed: the kept policy says off for that marker, until it is asked again (POLICY_TTL_MS). */
|
|
802
|
+
private markerRefused(marker: string, generation: number): void {
|
|
803
|
+
if (generation !== this.generation) return;
|
|
804
|
+
const value = this.policy?.value ?? POLICY_OFF;
|
|
805
|
+
this.policy = {
|
|
806
|
+
value: { allFolders: value.allFolders && marker !== FOLDER_MARKER, touchedFiles: value.touchedFiles && marker !== TOUCHED_MARKER },
|
|
807
|
+
at: this.now().getTime(),
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Keeps a key response's policy for folderPolicy. A full fetch (git
|
|
813
|
+
* bases, deltas) counts only when it answered; a probe counts whatever it
|
|
814
|
+
* got. Nothing from before a sign-out is kept.
|
|
815
|
+
*/
|
|
816
|
+
private rememberPolicy(fetched: KeyResult, generation: number, probe: boolean): void {
|
|
817
|
+
if (generation !== this.generation) return;
|
|
818
|
+
if (fetched.status === "unavailable" && !probe) return;
|
|
819
|
+
this.policy = { value: fetched.status === "ok" ? fetched.policy : POLICY_OFF, at: this.now().getTime() };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private async currentKey(signal?: AbortSignal): Promise<ArchiveKey | "disabled" | "unavailable"> {
|
|
823
|
+
if (this.key) return this.key;
|
|
824
|
+
const generation = this.generation;
|
|
825
|
+
const fetched = await this.uploader.fetchKey(signal);
|
|
826
|
+
this.rememberPolicy(fetched, generation, false);
|
|
827
|
+
if (fetched.status === "disabled") {
|
|
828
|
+
await this.disable(fetched.code);
|
|
829
|
+
return "disabled";
|
|
830
|
+
}
|
|
831
|
+
if (fetched.status === "unavailable") return "unavailable";
|
|
832
|
+
await this.enable(fetched.key);
|
|
833
|
+
return fetched.key;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* One capture under the session's lock. The collection hints around it keep
|
|
838
|
+
* a capture's per-entry garbage from stacking on top of the previous one's.
|
|
839
|
+
*/
|
|
840
|
+
private async captureArchive(state: SessionState, kind: ArchiveKind, turn: number, key: ArchiveKey, generation: number, options: CaptureOptions = {}): Promise<CaptureResult> {
|
|
841
|
+
hintGarbageCollection();
|
|
842
|
+
try {
|
|
843
|
+
return await this.captureArchiveOnce(state, kind, turn, key, generation, options);
|
|
844
|
+
} finally {
|
|
845
|
+
hintGarbageCollection();
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Pass 1, delta, pass 2 and the commit protocol of section 13.4. */
|
|
850
|
+
private async captureArchiveOnce(state: SessionState, kind: ArchiveKind, turn: number, key: ArchiveKey, generation: number, options: CaptureOptions): Promise<CaptureResult> {
|
|
851
|
+
const sessionId = state.session_id;
|
|
852
|
+
const sessionKey = stateKey(sessionId);
|
|
853
|
+
const rootKey = stateKey(state.root);
|
|
854
|
+
const { signal } = options;
|
|
855
|
+
const cache = await this.loadHashCache(rootKey);
|
|
856
|
+
const touched = state.marker === TOUCHED_MARKER;
|
|
857
|
+
let files: ScannedEntry[];
|
|
858
|
+
let deleted: string[] | undefined;
|
|
859
|
+
let excluded: ExcludedCounts;
|
|
860
|
+
/** The chain's entry list after this archive: the next baseline. */
|
|
861
|
+
let next: readonly ArchiveEntry[];
|
|
862
|
+
let git: ArchiveGit | null = null;
|
|
863
|
+
if (touched) {
|
|
864
|
+
const baseline = kind === "delta" ? await this.readBaseline(state) : [];
|
|
865
|
+
if (!baseline) {
|
|
866
|
+
await this.markStopped(sessionId, "baseline_missing");
|
|
867
|
+
return { status: "skipped", reason: "stopped" };
|
|
868
|
+
}
|
|
869
|
+
// Every path the session touched, and every file the chain holds (to see it go).
|
|
870
|
+
const paths = await this.touched.snapshot(sessionId);
|
|
871
|
+
for (const entry of baseline) paths.add(entry.path);
|
|
872
|
+
const scan = await scanTouchedFiles(state.root, paths, { excludedDirs: this.appDirs, includeCredentialFiles: this.includeCredentials, hashCache: cache, ...(signal ? { signal } : {}) });
|
|
873
|
+
const change = touchedChange(baseline, scan);
|
|
874
|
+
if (change.files.length === 0 && change.deleted.length === 0) {
|
|
875
|
+
if (cache.changed) await this.saveHashCache(rootKey, cache);
|
|
876
|
+
if (generation === this.generation) await this.noteUnchanged(state, kind, turn, options);
|
|
877
|
+
return { status: "skipped", reason: "unchanged" };
|
|
878
|
+
}
|
|
879
|
+
files = change.files;
|
|
880
|
+
deleted = kind === "delta" ? change.deleted : undefined;
|
|
881
|
+
excluded = scan.excluded;
|
|
882
|
+
next = change.next;
|
|
883
|
+
} else {
|
|
884
|
+
const scanning = scanArchiveTree(state.root, { excludedDirs: this.appDirs, includeCredentialFiles: this.includeCredentials, hashCache: cache, ...(signal ? { signal } : {}) });
|
|
885
|
+
// A plain folder sends no git block (workspace.git null), even inside a larger repository.
|
|
886
|
+
const withGit = state.marker !== FOLDER_MARKER;
|
|
887
|
+
const gitReading = kind === "base" && withGit ? readArchiveGit(state.root) : null;
|
|
888
|
+
const scan = await scanning;
|
|
889
|
+
files = scan.entries;
|
|
890
|
+
excluded = scan.excluded;
|
|
891
|
+
next = scan.entries;
|
|
892
|
+
if (kind === "delta") {
|
|
893
|
+
const baseline = await this.readBaseline(state);
|
|
894
|
+
if (!baseline) {
|
|
895
|
+
await this.markStopped(sessionId, "baseline_missing");
|
|
896
|
+
return { status: "skipped", reason: "stopped" };
|
|
897
|
+
}
|
|
898
|
+
const delta = computeArchiveDelta(baseline, scan.entries);
|
|
899
|
+
if (isArchiveDeltaEmpty(delta)) {
|
|
900
|
+
if (cache.changed) await this.saveHashCache(rootKey, cache);
|
|
901
|
+
if (generation === this.generation) await this.noteUnchanged(state, kind, turn, options);
|
|
902
|
+
return { status: "skipped", reason: "unchanged" };
|
|
903
|
+
}
|
|
904
|
+
files = delta.files;
|
|
905
|
+
deleted = delta.deleted;
|
|
906
|
+
}
|
|
907
|
+
git = withGit ? await (gitReading ?? readArchiveGit(state.root)) : null;
|
|
908
|
+
}
|
|
909
|
+
const archiveId = this.random.uuid();
|
|
910
|
+
const sequence = state.next_sequence;
|
|
911
|
+
const createdAt = this.now();
|
|
912
|
+
const manifest = manifestSource({
|
|
913
|
+
kind,
|
|
914
|
+
archiveId,
|
|
915
|
+
sessionId,
|
|
916
|
+
sequence,
|
|
917
|
+
turn,
|
|
918
|
+
createdAt,
|
|
919
|
+
parentArchiveId: kind === "base" ? null : state.last_archive_id,
|
|
920
|
+
...(kind === "delta" && options.trigger ? { trigger: options.trigger } : {}),
|
|
921
|
+
...(kind === "delta" && options.reason ? { reason: options.reason } : {}),
|
|
922
|
+
...(touched ? { scope: "touched" as const } : {}),
|
|
923
|
+
label: archiveLabel(state.root),
|
|
924
|
+
marker: state.marker,
|
|
925
|
+
git,
|
|
926
|
+
files,
|
|
927
|
+
...(deleted ? { deleted } : {}),
|
|
928
|
+
excluded,
|
|
929
|
+
});
|
|
930
|
+
const partial = join(this.dirs.tmp, `${archiveId}.partial`);
|
|
931
|
+
let sealed: Awaited<ReturnType<typeof writeSealedArchive>>;
|
|
932
|
+
try {
|
|
933
|
+
sealed = await writeSealedArchive(
|
|
934
|
+
{ root: state.root, manifest, createdAtSeconds: Math.floor(createdAt.getTime() / 1000), entries: files, ...(signal ? { signal } : {}) },
|
|
935
|
+
{ publicKey: key.publicKey },
|
|
936
|
+
partial,
|
|
937
|
+
);
|
|
938
|
+
} catch (error) {
|
|
939
|
+
await rm(partial, { force: true });
|
|
940
|
+
throw error;
|
|
941
|
+
}
|
|
942
|
+
if (generation !== this.generation || this.disabled || this.stoppedSessions.has(sessionId) || signal?.aborted) {
|
|
943
|
+
await rm(partial, { force: true });
|
|
944
|
+
if (this.disabled) return { status: "skipped", reason: "disabled" };
|
|
945
|
+
return { status: "skipped", reason: generation !== this.generation || this.stoppedSessions.has(sessionId) ? "stopped" : "cancelled" };
|
|
946
|
+
}
|
|
947
|
+
// 1-2: the sealed file and its queue record.
|
|
948
|
+
const sealedFile = `${archiveId}.orseal`;
|
|
949
|
+
await rename(partial, join(this.dirs.pending, sealedFile));
|
|
950
|
+
const request: ArchiveCreateRequest = {
|
|
951
|
+
archive_id: archiveId,
|
|
952
|
+
session_id: sessionId,
|
|
953
|
+
kind,
|
|
954
|
+
sequence,
|
|
955
|
+
turn,
|
|
956
|
+
parent_archive_id: kind === "base" ? null : state.last_archive_id,
|
|
957
|
+
size: sealed.size,
|
|
958
|
+
sha256: sealed.sha256,
|
|
959
|
+
kid: sealed.kid,
|
|
960
|
+
content: SEAL_CONTENT,
|
|
961
|
+
marker: state.marker,
|
|
962
|
+
};
|
|
963
|
+
const record: QueueRecord = {
|
|
964
|
+
v: 1,
|
|
965
|
+
archive_id: archiveId,
|
|
966
|
+
session_key: sessionKey,
|
|
967
|
+
request,
|
|
968
|
+
sealed_file: sealedFile,
|
|
969
|
+
...(kind === "delta" && options.trigger ? { trigger: options.trigger } : {}),
|
|
970
|
+
created_at: createdAt.toISOString(),
|
|
971
|
+
attempts: 0,
|
|
972
|
+
next_attempt_at: null,
|
|
973
|
+
recreates: 0,
|
|
974
|
+
upload: null,
|
|
975
|
+
};
|
|
976
|
+
await writeJsonAtomic(join(this.dirs.queue, `${archiveId}.json`), record);
|
|
977
|
+
// 3: the new baseline is the full scan (touched files: the chain's files); entries that changed while packing get a null hash so the next delta sends them again.
|
|
978
|
+
const unstable = new Set(sealed.unstable);
|
|
979
|
+
for (const path of unstable) cache.forget(path);
|
|
980
|
+
const baselineName = `${sessionKey}-${sequence}.json`;
|
|
981
|
+
await writeChunksAtomic(join(this.dirs.baselines, baselineName), baselineChunks(next, unstable));
|
|
982
|
+
// 4: commit. Until the server has accepted a final archive, the first
|
|
983
|
+
// one keeps the chain point before it, baseline included.
|
|
984
|
+
const previousBaseline = state.baseline;
|
|
985
|
+
const rewind = state.rewind ?? (kind === "delta" && options.trigger === "final" && !this.finalsAccepted
|
|
986
|
+
? { next_sequence: state.next_sequence, last_archive_id: state.last_archive_id, last_turn: state.last_turn, baseline: state.baseline }
|
|
987
|
+
: null);
|
|
988
|
+
await this.saveSession({
|
|
989
|
+
...state,
|
|
990
|
+
next_sequence: sequence + 1,
|
|
991
|
+
last_archive_id: archiveId,
|
|
992
|
+
last_turn: turn,
|
|
993
|
+
baseline: baselineName,
|
|
994
|
+
rewind,
|
|
995
|
+
final_due: options.trigger !== "final",
|
|
996
|
+
...(options.ended ? { ended: this.now().toISOString() } : {}),
|
|
997
|
+
updated_at: this.now().toISOString(),
|
|
998
|
+
});
|
|
999
|
+
if (previousBaseline && previousBaseline !== baselineName && previousBaseline !== rewind?.baseline) await rm(join(this.dirs.baselines, previousBaseline), { force: true });
|
|
1000
|
+
await this.saveHashCache(rootKey, cache);
|
|
1001
|
+
this.log("info", "OmniRush project archive queued", {
|
|
1002
|
+
sessionId,
|
|
1003
|
+
archiveId,
|
|
1004
|
+
kind,
|
|
1005
|
+
sequence,
|
|
1006
|
+
turn,
|
|
1007
|
+
...(options.trigger === "final" ? { trigger: "final", reason: options.reason } : {}),
|
|
1008
|
+
...(touched ? { scope: "touched" } : {}),
|
|
1009
|
+
bytes: sealed.size,
|
|
1010
|
+
files: files.length,
|
|
1011
|
+
...(deleted ? { deleted: deleted.length } : {}),
|
|
1012
|
+
...(unstable.size > 0 ? { unstable: unstable.size } : {}),
|
|
1013
|
+
});
|
|
1014
|
+
return { status: "queued", archiveId, kind, sequence, size: sealed.size };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** The entry list after the chain's last archive; null when it cannot be read (the chain cannot go on). */
|
|
1018
|
+
private async readBaseline(state: SessionState): Promise<ArchiveEntry[] | null> {
|
|
1019
|
+
return state.baseline ? parseBaselineText(await readText(join(this.dirs.baselines, state.baseline))) : null;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Nothing changed: after a turn the session's final is due (checked at the
|
|
1024
|
+
* next start), a final settles it. A touched-files session without a base
|
|
1025
|
+
* (nothing touched yet) stays registered, with the turns seen so far.
|
|
1026
|
+
*/
|
|
1027
|
+
private async noteUnchanged(state: SessionState, kind: ArchiveKind, turn: number, options: CaptureOptions): Promise<void> {
|
|
1028
|
+
if (kind === "base") {
|
|
1029
|
+
await this.saveSession({ ...state, turn_seen: turn, ...(options.ended ? { ended: this.now().toISOString() } : {}), updated_at: this.now().toISOString() });
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const final = options.trigger === "final";
|
|
1033
|
+
if (final && !state.final_due && !options.ended) return;
|
|
1034
|
+
await this.saveSession({
|
|
1035
|
+
...state,
|
|
1036
|
+
final_due: !final,
|
|
1037
|
+
...(options.ended ? { ended: this.now().toISOString() } : {}),
|
|
1038
|
+
updated_at: this.now().toISOString(),
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// --- drain -----------------------------------------------------------------------------
|
|
1043
|
+
|
|
1044
|
+
private async runDrain(): Promise<DrainResult> {
|
|
1045
|
+
const result: DrainResult = { uploaded: 0, pending: 0, dropped: 0, blocked: null, disabled: false };
|
|
1046
|
+
this.clearRetryTimer();
|
|
1047
|
+
const controller = new AbortController();
|
|
1048
|
+
this.drainController = controller;
|
|
1049
|
+
try {
|
|
1050
|
+
await this.start();
|
|
1051
|
+
const generation = this.generation;
|
|
1052
|
+
do {
|
|
1053
|
+
this.drainAgain = false;
|
|
1054
|
+
if (!(await this.drainOnce(result, controller.signal, generation))) break;
|
|
1055
|
+
} while (this.drainAgain && !controller.signal.aborted);
|
|
1056
|
+
const remaining = await this.listQueue();
|
|
1057
|
+
result.pending = remaining.length;
|
|
1058
|
+
if (!controller.signal.aborted && generation === this.generation && !result.blocked && !result.disabled) this.scheduleRetry(remaining);
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
this.log("warn", "OmniRush archive drain failed", { error: errorSummary(error) });
|
|
1061
|
+
} finally {
|
|
1062
|
+
if (this.drainController === controller) this.drainController = null;
|
|
1063
|
+
}
|
|
1064
|
+
return result;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* The queue in upload order: each session's jobs strictly by sequence (a
|
|
1069
|
+
* delta's parent must be uploaded first, whatever the clock did), sessions
|
|
1070
|
+
* by the creation time of their oldest job.
|
|
1071
|
+
*/
|
|
1072
|
+
private async orderedQueue(): Promise<QueueRecord[]> {
|
|
1073
|
+
const bySession = new Map<string, QueueRecord[]>();
|
|
1074
|
+
for (const record of await this.listQueue()) {
|
|
1075
|
+
const jobs = bySession.get(record.request.session_id) ?? [];
|
|
1076
|
+
jobs.push(record);
|
|
1077
|
+
bySession.set(record.request.session_id, jobs);
|
|
1078
|
+
}
|
|
1079
|
+
const sessions = [...bySession.values()].map((jobs) => jobs.sort((left, right) => left.request.sequence - right.request.sequence));
|
|
1080
|
+
sessions.sort((left, right) => left[0]!.created_at.localeCompare(right[0]!.created_at));
|
|
1081
|
+
return sessions.flat();
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** One pass over the queue; false when the drain must stop. */
|
|
1085
|
+
private async drainOnce(result: DrainResult, signal: AbortSignal, generation: number): Promise<boolean> {
|
|
1086
|
+
const records = await this.orderedQueue();
|
|
1087
|
+
const held = new Set<string>();
|
|
1088
|
+
const nowMs = this.now().getTime();
|
|
1089
|
+
for (const record of records) {
|
|
1090
|
+
if (signal.aborted || generation !== this.generation) return false;
|
|
1091
|
+
const sessionId = record.request.session_id;
|
|
1092
|
+
if (held.has(sessionId) || this.resettingSessions.has(sessionId)) continue;
|
|
1093
|
+
if (this.stoppedSessions.has(sessionId)) {
|
|
1094
|
+
await this.deleteJob(record);
|
|
1095
|
+
result.dropped += 1;
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
if (nowMs - Date.parse(record.created_at) > MAX_JOB_AGE_MS) {
|
|
1099
|
+
result.dropped += await this.stopSession(sessionId, "archive_expired");
|
|
1100
|
+
held.add(sessionId);
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
if (record.next_attempt_at && Date.parse(record.next_attempt_at) > nowMs) {
|
|
1104
|
+
held.add(sessionId);
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
const job = { request: record.request, sealedPath: join(this.dirs.pending, record.sealed_file), upload: record.upload, recreates: record.recreates };
|
|
1108
|
+
const persist = async () => {
|
|
1109
|
+
record.upload = job.upload;
|
|
1110
|
+
record.recreates = job.recreates;
|
|
1111
|
+
await writeJsonAtomic(join(this.dirs.queue, `${record.archive_id}.json`), record);
|
|
1112
|
+
};
|
|
1113
|
+
const outcome = await this.uploader.upload(job, persist, signal);
|
|
1114
|
+
switch (outcome.status) {
|
|
1115
|
+
case "uploaded":
|
|
1116
|
+
await this.deleteJob(record);
|
|
1117
|
+
result.uploaded += 1;
|
|
1118
|
+
this.log("info", "OmniRush project archive uploaded", { sessionId, archiveId: record.archive_id, kind: record.request.kind, sequence: record.request.sequence, bytes: record.request.size });
|
|
1119
|
+
if (record.trigger === "final") this.finalAccepted(sessionId, record.request.sequence);
|
|
1120
|
+
break;
|
|
1121
|
+
case "disabled":
|
|
1122
|
+
result.dropped += await this.disable(outcome.code);
|
|
1123
|
+
result.disabled = true;
|
|
1124
|
+
return false;
|
|
1125
|
+
case "blocked":
|
|
1126
|
+
result.blocked = outcome.reason;
|
|
1127
|
+
this.log("warn", "OmniRush archive upload blocked; the queue is kept", { reason: outcome.reason });
|
|
1128
|
+
return false;
|
|
1129
|
+
case "stop_session":
|
|
1130
|
+
if (record.trigger === "final" && outcome.code === "archive_parent_mismatch" && !this.finalsAccepted) {
|
|
1131
|
+
// A server without final archives refuses the repeated turn number: not a broken chain.
|
|
1132
|
+
result.dropped += await this.rewindRefusedFinal(record);
|
|
1133
|
+
} else {
|
|
1134
|
+
// The server does not take this marker: its policy is off here too until the next answer, so no other chain starts meanwhile.
|
|
1135
|
+
if (outcome.code === ARCHIVE_MARKER_NOT_ALLOWED) this.markerRefused(record.request.marker, generation);
|
|
1136
|
+
result.dropped += await this.stopSession(sessionId, outcome.code);
|
|
1137
|
+
}
|
|
1138
|
+
held.add(sessionId);
|
|
1139
|
+
break;
|
|
1140
|
+
case "rekey":
|
|
1141
|
+
this.key = null;
|
|
1142
|
+
await this.saveArchiverState();
|
|
1143
|
+
if (record.request.kind === "base") {
|
|
1144
|
+
// Nothing exists on the server yet: the base is captured again with the current key.
|
|
1145
|
+
result.dropped += await this.resetToNoBase(sessionId);
|
|
1146
|
+
} else {
|
|
1147
|
+
// The server does not take this marker: its policy is off here too until the next answer, so no other chain starts meanwhile.
|
|
1148
|
+
if (outcome.code === ARCHIVE_MARKER_NOT_ALLOWED) this.markerRefused(record.request.marker, generation);
|
|
1149
|
+
result.dropped += await this.stopSession(sessionId, outcome.code);
|
|
1150
|
+
}
|
|
1151
|
+
held.add(sessionId);
|
|
1152
|
+
break;
|
|
1153
|
+
case "retry_later": {
|
|
1154
|
+
record.attempts += 1;
|
|
1155
|
+
const delay = Math.min(JOB_RETRY_MAX_MS, JOB_RETRY_BASE_MS * 2 ** (record.attempts - 1));
|
|
1156
|
+
record.next_attempt_at = new Date(nowMs + delay).toISOString();
|
|
1157
|
+
await persist();
|
|
1158
|
+
held.add(sessionId);
|
|
1159
|
+
this.log("warn", "OmniRush archive upload deferred", { sessionId, archiveId: record.archive_id, attempts: record.attempts, reason: outcome.reason });
|
|
1160
|
+
break;
|
|
1161
|
+
}
|
|
1162
|
+
case "aborted":
|
|
1163
|
+
return false;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
return true;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
private scheduleRetry(records: readonly QueueRecord[]): void {
|
|
1170
|
+
const next = records.map((record) => (record.next_attempt_at ? Date.parse(record.next_attempt_at) : Number.NaN)).filter(Number.isFinite);
|
|
1171
|
+
if (next.length === 0) return;
|
|
1172
|
+
const delay = Math.max(1_000, Math.min(...next) - this.now().getTime());
|
|
1173
|
+
this.retryTimer = setTimeout(() => {
|
|
1174
|
+
this.retryTimer = null;
|
|
1175
|
+
void this.drain();
|
|
1176
|
+
}, delay);
|
|
1177
|
+
this.retryTimer.unref?.();
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
private clearRetryTimer(): void {
|
|
1181
|
+
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
1182
|
+
this.retryTimer = null;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// --- state transitions -------------------------------------------------------------------
|
|
1186
|
+
|
|
1187
|
+
/** 428 / 503: archiving is off for the user. Every queued job goes; sessions that lost jobs stop. */
|
|
1188
|
+
private async disable(code: string): Promise<number> {
|
|
1189
|
+
if (!this.disabled) this.log("info", "OmniRush project archiving is off for this account", { code });
|
|
1190
|
+
this.disabled = code;
|
|
1191
|
+
this.key = null;
|
|
1192
|
+
this.policy = { value: POLICY_OFF, at: this.now().getTime() };
|
|
1193
|
+
await this.saveArchiverState();
|
|
1194
|
+
const records = await this.listQueue();
|
|
1195
|
+
for (const record of records) {
|
|
1196
|
+
await this.deleteJob(record);
|
|
1197
|
+
this.requestStop(record.request.session_id, "archive_disabled");
|
|
1198
|
+
}
|
|
1199
|
+
return records.length;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
private async enable(key: ArchiveKey): Promise<void> {
|
|
1203
|
+
const changed = this.disabled !== null || this.key?.kid !== key.kid;
|
|
1204
|
+
this.disabled = null;
|
|
1205
|
+
this.key = key;
|
|
1206
|
+
if (changed) await this.saveArchiverState();
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/** Drops the session's queued jobs and marks it stopped; returns how many jobs were dropped. */
|
|
1210
|
+
private async stopSession(sessionId: string, code: string): Promise<number> {
|
|
1211
|
+
const records = (await this.listQueue()).filter((record) => record.request.session_id === sessionId);
|
|
1212
|
+
for (const record of records) await this.deleteJob(record);
|
|
1213
|
+
this.requestStop(sessionId, code);
|
|
1214
|
+
this.log("warn", "OmniRush archiving stopped for a session", { sessionId, code, droppedJobs: records.length });
|
|
1215
|
+
return records.length;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/** Marks the session stopped now (in memory) and in its state file once its lock is free. */
|
|
1219
|
+
private requestStop(sessionId: string, code: string): void {
|
|
1220
|
+
if (this.stoppedSessions.has(sessionId)) return;
|
|
1221
|
+
this.stoppedSessions.set(sessionId, code);
|
|
1222
|
+
const generation = this.generation;
|
|
1223
|
+
void this.withSession(sessionId, async () => {
|
|
1224
|
+
if (generation === this.generation) await this.markStopped(sessionId, code);
|
|
1225
|
+
}).catch((error) => this.log("warn", "OmniRush archive session stop failed", { sessionId, error: errorSummary(error) }));
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** Under the session lock: the stopped flag, and any job queued since; a stopped session keeps no touched paths. */
|
|
1229
|
+
private async markStopped(sessionId: string, code: string): Promise<void> {
|
|
1230
|
+
for (const record of await this.listQueue()) {
|
|
1231
|
+
if (record.request.session_id === sessionId) await this.deleteJob(record);
|
|
1232
|
+
}
|
|
1233
|
+
await this.touched.forget(sessionId);
|
|
1234
|
+
const state = await this.loadSession(sessionId);
|
|
1235
|
+
if (state && !state.stopped) await this.saveSession({ ...state, stopped: code, updated_at: this.now().toISOString() });
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* The server accepted a final archive: it takes them, and the chain up to
|
|
1240
|
+
* `sequence` stands, so the session no longer needs its rewind point.
|
|
1241
|
+
*/
|
|
1242
|
+
private finalAccepted(sessionId: string, sequence: number): void {
|
|
1243
|
+
this.finalsAccepted = true;
|
|
1244
|
+
const generation = this.generation;
|
|
1245
|
+
void this.withSession(sessionId, async () => {
|
|
1246
|
+
if (generation !== this.generation) return;
|
|
1247
|
+
const state = await this.loadSession(sessionId);
|
|
1248
|
+
const rewind = state?.rewind;
|
|
1249
|
+
if (!state || !rewind || rewind.next_sequence > sequence) return;
|
|
1250
|
+
if (rewind.baseline && rewind.baseline !== state.baseline) await rm(join(this.dirs.baselines, rewind.baseline), { force: true });
|
|
1251
|
+
// Bookkeeping only: updated_at stays the time of the session's last archive.
|
|
1252
|
+
await this.saveSession({ ...state, rewind: null });
|
|
1253
|
+
}).catch((error) => this.log("warn", "OmniRush archive session update failed", { sessionId, error: errorSummary(error) }));
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* 409 archive_parent_mismatch on a final archive before any was accepted:
|
|
1258
|
+
* a server that does not take final archives (their turn repeats the
|
|
1259
|
+
* parent's). The final is dropped, with the jobs chained on it, and the
|
|
1260
|
+
* chain goes back to where it was before it; a turn delta dropped with it
|
|
1261
|
+
* is captured again on top, so later turns keep uploading. No final
|
|
1262
|
+
* archive is captured again until the app restarts. Returns the jobs
|
|
1263
|
+
* dropped.
|
|
1264
|
+
*/
|
|
1265
|
+
private async rewindRefusedFinal(record: QueueRecord): Promise<number> {
|
|
1266
|
+
const sessionId = record.request.session_id;
|
|
1267
|
+
const from = record.request.sequence;
|
|
1268
|
+
if (!this.finalsRefused) {
|
|
1269
|
+
this.finalsRefused = true;
|
|
1270
|
+
this.log("info", "The omnirush.ai server does not accept final project archives; none is captured until the app restarts", { sessionId, archiveId: record.archive_id });
|
|
1271
|
+
}
|
|
1272
|
+
const chained = (job: QueueRecord) => job.request.session_id === sessionId && job.request.sequence >= from;
|
|
1273
|
+
const dropped = (await this.listQueue()).filter(chained);
|
|
1274
|
+
// Held out of the drain until the chain is back where the server has it.
|
|
1275
|
+
this.resettingSessions.add(sessionId);
|
|
1276
|
+
const generation = this.generation;
|
|
1277
|
+
let recaptured = false;
|
|
1278
|
+
void this.withSession(sessionId, async () => {
|
|
1279
|
+
if (generation !== this.generation) return;
|
|
1280
|
+
// Jobs committed since (a capture that held the lock) are chained on it too.
|
|
1281
|
+
const jobs = (await this.listQueue()).filter(chained);
|
|
1282
|
+
const state = await this.loadSession(sessionId);
|
|
1283
|
+
const rewind = state?.rewind;
|
|
1284
|
+
if (!state || state.stopped) {
|
|
1285
|
+
for (const job of jobs) await this.deleteJob(job);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
if (!rewind || rewind.next_sequence !== from) {
|
|
1289
|
+
// Nothing to go back to: the chain ends as for any conflict.
|
|
1290
|
+
this.stoppedSessions.set(sessionId, "archive_parent_mismatch");
|
|
1291
|
+
await this.markStopped(sessionId, "archive_parent_mismatch");
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
// The state first: a crash before the jobs go leaves them past next_sequence, which start() removes.
|
|
1295
|
+
const restored: SessionState = { ...state, ...rewind, rewind: null, final_due: true, updated_at: this.now().toISOString() };
|
|
1296
|
+
await this.saveSession(restored);
|
|
1297
|
+
if (state.baseline && state.baseline !== rewind.baseline) await rm(join(this.dirs.baselines, state.baseline), { force: true });
|
|
1298
|
+
for (const job of jobs) await this.deleteJob(job);
|
|
1299
|
+
const turn = Math.max(-1, ...jobs.filter((job) => job.trigger !== "final").map((job) => job.request.turn));
|
|
1300
|
+
if (turn > (restored.last_turn ?? -1) && this.key) {
|
|
1301
|
+
const again = await this.captureArchive(restored, "delta", turn, this.key, generation, { trigger: "turn" });
|
|
1302
|
+
recaptured = again.status === "queued";
|
|
1303
|
+
}
|
|
1304
|
+
})
|
|
1305
|
+
.catch((error) => this.log("warn", "OmniRush archive session rewind failed", { sessionId, error: errorSummary(error) }))
|
|
1306
|
+
.finally(() => {
|
|
1307
|
+
this.resettingSessions.delete(sessionId);
|
|
1308
|
+
if (recaptured && generation === this.generation) void this.drain();
|
|
1309
|
+
});
|
|
1310
|
+
return dropped.length;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/** A base sealed to an unknown kid: forget it so the next capture seals a new base with the current key. */
|
|
1314
|
+
private async resetToNoBase(sessionId: string): Promise<number> {
|
|
1315
|
+
const dropped = (await this.listQueue()).filter((record) => record.request.session_id === sessionId);
|
|
1316
|
+
for (const record of dropped) await this.deleteJob(record);
|
|
1317
|
+
// Held out of the drain until the reset has run: a delta packed on top of
|
|
1318
|
+
// the dropped base would otherwise be sent and end the chain with a 409.
|
|
1319
|
+
this.resettingSessions.add(sessionId);
|
|
1320
|
+
const generation = this.generation;
|
|
1321
|
+
void this.withSession(sessionId, async () => {
|
|
1322
|
+
if (generation !== this.generation) return;
|
|
1323
|
+
for (const record of await this.listQueue()) {
|
|
1324
|
+
if (record.request.session_id === sessionId) await this.deleteJob(record);
|
|
1325
|
+
}
|
|
1326
|
+
const state = await this.loadSession(sessionId);
|
|
1327
|
+
if (!state || state.stopped) return;
|
|
1328
|
+
if (state.baseline) await rm(join(this.dirs.baselines, state.baseline), { force: true });
|
|
1329
|
+
if (state.rewind?.baseline) await rm(join(this.dirs.baselines, state.rewind.baseline), { force: true });
|
|
1330
|
+
await this.saveSession({ ...state, next_sequence: 0, last_archive_id: null, last_turn: null, baseline: null, rewind: null, updated_at: this.now().toISOString() });
|
|
1331
|
+
})
|
|
1332
|
+
.catch((error) => this.log("warn", "OmniRush archive session reset failed", { sessionId, error: errorSummary(error) }))
|
|
1333
|
+
.finally(() => this.resettingSessions.delete(sessionId));
|
|
1334
|
+
this.log("info", "OmniRush archive key changed; the session's base archive will be captured again", { sessionId });
|
|
1335
|
+
return dropped.length;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// --- files -------------------------------------------------------------------------------
|
|
1339
|
+
|
|
1340
|
+
private sessionPath(sessionId: string): string {
|
|
1341
|
+
return join(this.dirs.sessions, `${stateKey(sessionId)}.json`);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private async loadSession(sessionId: string): Promise<SessionState | null> {
|
|
1345
|
+
const parsed = sessionStateSchema.safeParse(await readJsonFile(this.sessionPath(sessionId)));
|
|
1346
|
+
return parsed.success && parsed.data.session_id === sessionId ? parsed.data : null;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
private async saveSession(state: SessionState): Promise<void> {
|
|
1350
|
+
await writeJsonAtomic(this.sessionPath(state.session_id), state);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
private async loadHashCache(rootKey: string): Promise<ArchiveHashCache> {
|
|
1354
|
+
return ArchiveHashCache.fromText(await readText(join(this.dirs.hashCache, `${rootKey}.json`)));
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
private async saveHashCache(rootKey: string, cache: ArchiveHashCache): Promise<void> {
|
|
1358
|
+
await writeChunksAtomic(join(this.dirs.hashCache, `${rootKey}.json`), cache.jsonChunks());
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
private async saveArchiverState(): Promise<void> {
|
|
1362
|
+
await writeJsonAtomic(join(this.dir, "state.json"), {
|
|
1363
|
+
v: 1,
|
|
1364
|
+
disabled: this.disabled,
|
|
1365
|
+
key: this.key ? { kid: this.key.kid, public_key: this.key.publicKey.toString("base64"), alg: this.key.alg } : null,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
private async listQueue(): Promise<QueueRecord[]> {
|
|
1370
|
+
let names: string[];
|
|
1371
|
+
try {
|
|
1372
|
+
names = await readdir(this.dirs.queue);
|
|
1373
|
+
} catch {
|
|
1374
|
+
return [];
|
|
1375
|
+
}
|
|
1376
|
+
const records: QueueRecord[] = [];
|
|
1377
|
+
for (const name of names) {
|
|
1378
|
+
if (!name.endsWith(".json")) continue;
|
|
1379
|
+
const parsed = queueRecordSchema.safeParse(await readJsonFile(join(this.dirs.queue, name)));
|
|
1380
|
+
if (parsed.success && name === `${parsed.data.archive_id}.json`) records.push(parsed.data);
|
|
1381
|
+
}
|
|
1382
|
+
return records;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
private async deleteJob(record: QueueRecord): Promise<void> {
|
|
1386
|
+
await rm(join(this.dirs.pending, record.sealed_file), { force: true });
|
|
1387
|
+
await rm(join(this.dirs.queue, `${record.archive_id}.json`), { force: true });
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** Startup (section 13.4): tmp emptied, uncommitted and orphaned files removed, stale jobs dropped. */
|
|
1391
|
+
private async recover(): Promise<void> {
|
|
1392
|
+
for (const dir of Object.values(this.dirs)) await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
1393
|
+
await rm(this.dirs.tmp, { recursive: true, force: true });
|
|
1394
|
+
await mkdir(this.dirs.tmp, { recursive: true, mode: 0o700 });
|
|
1395
|
+
for (const name of await readdir(this.dir)) {
|
|
1396
|
+
if (name.endsWith(".tmp")) await rm(join(this.dir, name), { force: true });
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
const saved = archiverStateSchema.safeParse(await readJsonFile(join(this.dir, "state.json")));
|
|
1400
|
+
if (saved.success) {
|
|
1401
|
+
this.disabled = saved.data.disabled;
|
|
1402
|
+
const publicKey = saved.data.key ? Buffer.from(saved.data.key.public_key, "base64") : null;
|
|
1403
|
+
if (saved.data.key && publicKey?.length === 32) this.key = { kid: saved.data.key.kid, publicKey, alg: saved.data.key.alg };
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
const sessions = new Map<string, SessionState>();
|
|
1407
|
+
for (const name of await readdir(this.dirs.sessions)) {
|
|
1408
|
+
const path = join(this.dirs.sessions, name);
|
|
1409
|
+
if (!name.endsWith(".json")) {
|
|
1410
|
+
await rm(path, { force: true });
|
|
1411
|
+
continue;
|
|
1412
|
+
}
|
|
1413
|
+
const parsed = sessionStateSchema.safeParse(await readJsonFile(path));
|
|
1414
|
+
if (parsed.success && name === `${stateKey(parsed.data.session_id)}.json`) sessions.set(stateKey(parsed.data.session_id), parsed.data);
|
|
1415
|
+
else await rm(path, { force: true });
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
const pendingFiles = new Set(await readdir(this.dirs.pending));
|
|
1419
|
+
const kept = new Set<string>();
|
|
1420
|
+
const nowMs = this.now().getTime();
|
|
1421
|
+
const stop = async (session: SessionState, code: string) => {
|
|
1422
|
+
if (session.stopped) return;
|
|
1423
|
+
session.stopped = code;
|
|
1424
|
+
session.updated_at = this.now().toISOString();
|
|
1425
|
+
await this.saveSession(session);
|
|
1426
|
+
};
|
|
1427
|
+
for (const name of await readdir(this.dirs.queue)) {
|
|
1428
|
+
const path = join(this.dirs.queue, name);
|
|
1429
|
+
const parsed = name.endsWith(".json") ? queueRecordSchema.safeParse(await readJsonFile(path)) : null;
|
|
1430
|
+
if (!parsed?.success || name !== `${parsed.data.archive_id}.json`) {
|
|
1431
|
+
await rm(path, { force: true });
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
const record = parsed.data;
|
|
1435
|
+
const session = sessions.get(record.session_key);
|
|
1436
|
+
if (!session || session.stopped || record.request.sequence >= session.next_sequence) {
|
|
1437
|
+
// A crash before the session commit (step 4), or a stopped session: never uploaded.
|
|
1438
|
+
await this.deleteJob(record);
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1441
|
+
if (!pendingFiles.has(record.sealed_file)) {
|
|
1442
|
+
await this.deleteJob(record);
|
|
1443
|
+
await stop(session, "archive_file_missing");
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
if (nowMs - Date.parse(record.created_at) > MAX_JOB_AGE_MS) {
|
|
1447
|
+
await this.deleteJob(record);
|
|
1448
|
+
await stop(session, "archive_expired");
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
kept.add(record.sealed_file);
|
|
1452
|
+
}
|
|
1453
|
+
for (const name of pendingFiles) if (!kept.has(name)) await rm(join(this.dirs.pending, name), { force: true });
|
|
1454
|
+
|
|
1455
|
+
const namedBaselines = new Set([...sessions.values()].flatMap((session) => [session.baseline, session.rewind?.baseline ?? null]).filter((name) => name !== null));
|
|
1456
|
+
for (const name of await readdir(this.dirs.baselines)) {
|
|
1457
|
+
if (!namedBaselines.has(name)) await rm(join(this.dirs.baselines, name), { force: true });
|
|
1458
|
+
}
|
|
1459
|
+
for (const name of await readdir(this.dirs.hashCache)) {
|
|
1460
|
+
if (name.endsWith(".tmp")) await rm(join(this.dirs.hashCache, name), { force: true });
|
|
1461
|
+
}
|
|
1462
|
+
// Touched paths only of touched-files sessions still archiving.
|
|
1463
|
+
for (const name of await readdir(this.dirs.touched)) {
|
|
1464
|
+
const session = name.endsWith(".jsonl") ? sessions.get(name.slice(0, -".jsonl".length)) : undefined;
|
|
1465
|
+
if (!session || session.marker !== TOUCHED_MARKER || session.stopped || session.ended) await rm(join(this.dirs.touched, name), { force: true });
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|