sift-light 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +0 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +661 -0
- package/README.md +177 -0
- package/README.zh-CN.md +177 -0
- package/SECURITY.md +25 -0
- package/package.json +142 -0
- package/plugins/sift-light/.claude-plugin/plugin.json +10 -0
- package/plugins/sift-light/.codex-plugin/plugin.json +20 -0
- package/plugins/sift-light/.mcp.json +24 -0
- package/plugins/sift-light/LICENSE +661 -0
- package/plugins/sift-light/concept-worker.mjs +567 -0
- package/plugins/sift-light/hooks/THIRD_PARTY_NOTICES.txt +70 -0
- package/plugins/sift-light/hooks/hooks.json +16 -0
- package/plugins/sift-light/hooks/search-policy.mjs +4113 -0
- package/plugins/sift-light/hooks/tree-sitter-bash.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter-powershell.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter.wasm +0 -0
- package/plugins/sift-light/kimi.plugin.json +36 -0
- package/plugins/sift-light/mcp-server.mjs +1 -0
- package/plugins/sift-light/omp-extension.mjs +20914 -0
- package/plugins/sift-light/package.json +11 -0
- package/plugins/sift-light/syntax-worker.mjs +280 -0
- package/plugins/sift-light/syntax-worker.toml +2 -0
- package/src/analysis-evidence.ts +43 -0
- package/src/analysis-limits.ts +27 -0
- package/src/analysis-store.ts +559 -0
- package/src/analysis-term-pages.ts +73 -0
- package/src/analysis-types.ts +162 -0
- package/src/audit-search.ts +391 -0
- package/src/capped-lines.ts +112 -0
- package/src/concept-embedding-cache.ts +186 -0
- package/src/concept-inference.ts +218 -0
- package/src/concept-model.ts +96 -0
- package/src/concept-search.ts +577 -0
- package/src/concept-setup.ts +75 -0
- package/src/concept-source-generation.ts +268 -0
- package/src/concept-worker.mjs +567 -0
- package/src/concept-worker.ts +210 -0
- package/src/config-reader.ts +283 -0
- package/src/config.ts +37 -0
- package/src/context-budget.ts +26 -0
- package/src/discovery-errors.ts +2 -0
- package/src/errors.ts +95 -0
- package/src/evidence-candidates.ts +344 -0
- package/src/evidence-ranking.ts +48 -0
- package/src/evidence-service.ts +1047 -0
- package/src/evidence-validation.ts +466 -0
- package/src/evidence-validity.ts +33 -0
- package/src/excerpt.ts +53 -0
- package/src/file-discovery.ts +150 -0
- package/src/file-metadata-filter.ts +46 -0
- package/src/format.ts +407 -0
- package/src/git-diff.ts +195 -0
- package/src/git-process.ts +189 -0
- package/src/git-repository.ts +367 -0
- package/src/git-source.ts +385 -0
- package/src/historical-paths.ts +205 -0
- package/src/hybrid-search.ts +340 -0
- package/src/import-model.ts +611 -0
- package/src/import-navigation.ts +100 -0
- package/src/import-resolution.ts +240 -0
- package/src/index.ts +97 -0
- package/src/inspect-batch.ts +265 -0
- package/src/inspect.ts +271 -0
- package/src/language-capabilities.ts +157 -0
- package/src/language-capability-definitions.ts +204 -0
- package/src/literal-search.ts +25 -0
- package/src/mcp-cli.ts +21 -0
- package/src/mcp-model-output.ts +120 -0
- package/src/mcp-output.ts +9 -0
- package/src/mcp-semantic-judge.ts +39 -0
- package/src/mcp-server.mjs +14064 -0
- package/src/mcp-server.ts +157 -0
- package/src/mcp-stdio.ts +114 -0
- package/src/mcp.ts +631 -0
- package/src/model-error.ts +120 -0
- package/src/multi-term-search.ts +122 -0
- package/src/omp-index.ts +277 -0
- package/src/omp-marketplace-entry.ts +7 -0
- package/src/operation-lifecycle.ts +413 -0
- package/src/operation-output.ts +100 -0
- package/src/owned-parallel.ts +22 -0
- package/src/owned-process.ts +102 -0
- package/src/owned-task-queue.ts +38 -0
- package/src/package-version.ts +3 -0
- package/src/path-policy.ts +203 -0
- package/src/pi-search-policy.ts +33 -0
- package/src/prompt-guidelines.ts +54 -0
- package/src/python-outline.ts +231 -0
- package/src/record-value.ts +3 -0
- package/src/redaction.ts +95 -0
- package/src/request-contract-catalog.ts +215 -0
- package/src/request-contract-error.ts +96 -0
- package/src/request-contract.ts +481 -0
- package/src/request.ts +132 -0
- package/src/result-statistics.ts +178 -0
- package/src/rg.ts +574 -0
- package/src/ripgrep-diagnostics.ts +122 -0
- package/src/ripgrep-executable.ts +43 -0
- package/src/runtime.ts +53 -0
- package/src/scan-revisions.ts +148 -0
- package/src/script-runtime.ts +11 -0
- package/src/search-policy-commands.ts +170 -0
- package/src/search-policy-hook.ts +61 -0
- package/src/search-policy-recovery.ts +388 -0
- package/src/search-policy-shell.ts +280 -0
- package/src/search-policy.ts +102 -0
- package/src/search-retention.ts +77 -0
- package/src/semantic-judge-batches.ts +135 -0
- package/src/semantic-judge.ts +519 -0
- package/src/service.ts +945 -0
- package/src/session-summary.ts +100 -0
- package/src/snapshot-store.ts +203 -0
- package/src/source-access.ts +288 -0
- package/src/source-continuations.ts +168 -0
- package/src/source-document.ts +277 -0
- package/src/source-inspection.ts +731 -0
- package/src/source-pages.ts +112 -0
- package/src/source.ts +286 -0
- package/src/structural-search.ts +125 -0
- package/src/structure.ts +259 -0
- package/src/summary-previews.ts +96 -0
- package/src/summary.ts +79 -0
- package/src/syntax-facts.ts +464 -0
- package/src/syntax-fields.ts +161 -0
- package/src/syntax-search.ts +361 -0
- package/src/syntax-tree.ts +30 -0
- package/src/syntax-types.ts +72 -0
- package/src/syntax-worker.mjs +280 -0
- package/src/syntax-worker.toml +2 -0
- package/src/syntax-worker.ts +143 -0
- package/src/syntax.ts +259 -0
- package/src/test-navigation-facts.ts +261 -0
- package/src/test-navigation.ts +543 -0
- package/src/tool-schema.ts +321 -0
- package/src/tui/dashboard.ts +107 -0
- package/src/tui/layout.ts +291 -0
- package/src/tui/palette.ts +73 -0
- package/src/tui/presentation.ts +240 -0
- package/src/tui/renderers.ts +106 -0
- package/src/types.ts +368 -0
- package/src/validation-output.ts +148 -0
- package/src/validation-types.ts +48 -0
- package/src/workspace-files.ts +142 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const OPERATION_INITIAL_WAIT_MS = 5_000;
|
|
4
|
+
export const OPERATION_DEFAULT_DEADLINE_MS = 10 * 60_000;
|
|
5
|
+
export const OPERATION_DEFAULT_LEASE_MS = 2 * 60_000;
|
|
6
|
+
export const OPERATION_RESULT_TTL_MS = 10 * 60_000;
|
|
7
|
+
export const MAX_ACTIVE_OPERATIONS = 8;
|
|
8
|
+
export const MAX_RETAINED_OPERATIONS = 32;
|
|
9
|
+
|
|
10
|
+
export type OperationState = "running" | "complete" | "failed" | "cancelled" | "expired";
|
|
11
|
+
|
|
12
|
+
export interface OperationMetadata {
|
|
13
|
+
mode: "concept" | "hybrid";
|
|
14
|
+
redact: boolean;
|
|
15
|
+
cwd: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface OperationProgress {
|
|
19
|
+
phase: string;
|
|
20
|
+
completed?: number;
|
|
21
|
+
total?: number;
|
|
22
|
+
detail?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OperationSnapshot<T> {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly state: OperationState;
|
|
28
|
+
readonly startedAt: number;
|
|
29
|
+
readonly deadlineAt: number;
|
|
30
|
+
readonly leaseExpiresAt: number;
|
|
31
|
+
readonly metadata: OperationMetadata;
|
|
32
|
+
readonly progress?: OperationProgress;
|
|
33
|
+
readonly result?: T;
|
|
34
|
+
readonly error?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface OperationEntry<T> {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
readonly startedAt: number;
|
|
40
|
+
readonly deadlineAt: number;
|
|
41
|
+
readonly controller: AbortController;
|
|
42
|
+
readonly promise: Promise<T>;
|
|
43
|
+
state: OperationState;
|
|
44
|
+
leaseExpiresAt: number;
|
|
45
|
+
readonly metadata: OperationMetadata;
|
|
46
|
+
progress?: OperationProgress;
|
|
47
|
+
result?: T;
|
|
48
|
+
error?: unknown;
|
|
49
|
+
settled: boolean;
|
|
50
|
+
retainedAt: number | undefined;
|
|
51
|
+
timer: ReturnType<typeof setTimeout> | undefined;
|
|
52
|
+
leaseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
53
|
+
readonly waiters: Set<() => void>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type OperationWaitResult<T> =
|
|
57
|
+
| { state: "running"; operation: OperationSnapshot<T> }
|
|
58
|
+
| { state: "complete"; operation: OperationSnapshot<T>; result: T }
|
|
59
|
+
| { state: "failed"; operation: OperationSnapshot<T>; error: unknown }
|
|
60
|
+
| { state: "cancelled" | "expired"; operation: OperationSnapshot<T>; error: unknown };
|
|
61
|
+
|
|
62
|
+
export interface OperationLifecycleOptions {
|
|
63
|
+
now?: () => number;
|
|
64
|
+
maxOperations?: number;
|
|
65
|
+
deadlineMs?: number;
|
|
66
|
+
leaseMs?: number;
|
|
67
|
+
resultTtlMs?: number;
|
|
68
|
+
maxRetainedOperations?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function abortReason(signal: AbortSignal | undefined): Error {
|
|
72
|
+
return signal?.reason instanceof Error ? signal.reason : new Error("Operation was cancelled");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function snapshot<T>(entry: OperationEntry<T>): OperationSnapshot<T> {
|
|
76
|
+
return {
|
|
77
|
+
id: entry.id,
|
|
78
|
+
state: entry.state,
|
|
79
|
+
startedAt: entry.startedAt,
|
|
80
|
+
deadlineAt: entry.deadlineAt,
|
|
81
|
+
leaseExpiresAt: entry.leaseExpiresAt,
|
|
82
|
+
metadata: entry.metadata,
|
|
83
|
+
...(entry.progress ? { progress: { ...entry.progress } } : {}),
|
|
84
|
+
...(entry.result === undefined ? {} : { result: entry.result }),
|
|
85
|
+
...(entry.error === undefined ? {} : { error: entry.error }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Owns long-running work and its retained result for one service session. */
|
|
90
|
+
export class OperationLifecycle<T> {
|
|
91
|
+
readonly #operations = new Map<string, OperationEntry<T>>();
|
|
92
|
+
readonly #pending = new Set<Promise<T>>();
|
|
93
|
+
readonly #now: () => number;
|
|
94
|
+
readonly #maxOperations: number;
|
|
95
|
+
readonly #deadlineMs: number;
|
|
96
|
+
readonly #leaseMs: number;
|
|
97
|
+
readonly #resultTtlMs: number;
|
|
98
|
+
readonly #maxRetainedOperations: number;
|
|
99
|
+
#closed = false;
|
|
100
|
+
|
|
101
|
+
constructor(options: OperationLifecycleOptions = {}) {
|
|
102
|
+
this.#now = options.now ?? Date.now;
|
|
103
|
+
this.#maxOperations = options.maxOperations ?? MAX_ACTIVE_OPERATIONS;
|
|
104
|
+
this.#deadlineMs = options.deadlineMs ?? OPERATION_DEFAULT_DEADLINE_MS;
|
|
105
|
+
this.#leaseMs = options.leaseMs ?? OPERATION_DEFAULT_LEASE_MS;
|
|
106
|
+
this.#resultTtlMs = options.resultTtlMs ?? OPERATION_RESULT_TTL_MS;
|
|
107
|
+
this.#maxRetainedOperations = options.maxRetainedOperations ?? MAX_RETAINED_OPERATIONS;
|
|
108
|
+
if (!Number.isSafeInteger(this.#maxOperations) || this.#maxOperations < 1)
|
|
109
|
+
throw new Error("maxOperations must be a positive safe integer");
|
|
110
|
+
if (!Number.isSafeInteger(this.#deadlineMs) || this.#deadlineMs < 1)
|
|
111
|
+
throw new Error("deadlineMs must be a positive safe integer");
|
|
112
|
+
if (!Number.isSafeInteger(this.#leaseMs) || this.#leaseMs < 1)
|
|
113
|
+
throw new Error("leaseMs must be a positive safe integer");
|
|
114
|
+
if (!Number.isSafeInteger(this.#resultTtlMs) || this.#resultTtlMs < 1)
|
|
115
|
+
throw new Error("resultTtlMs must be a positive safe integer");
|
|
116
|
+
if (!Number.isSafeInteger(this.#maxRetainedOperations) || this.#maxRetainedOperations < 1)
|
|
117
|
+
throw new Error("maxRetainedOperations must be a positive safe integer");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get size(): number {
|
|
121
|
+
this.#expireRetained();
|
|
122
|
+
return this.#operations.size;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
start(
|
|
126
|
+
run: (signal: AbortSignal, operationId: string) => Promise<T>,
|
|
127
|
+
metadata: OperationMetadata,
|
|
128
|
+
): OperationSnapshot<T> & {
|
|
129
|
+
promise: Promise<T>;
|
|
130
|
+
} {
|
|
131
|
+
this.#expireRetained();
|
|
132
|
+
if (this.#closed) throw new Error("Operation lifecycle is closed");
|
|
133
|
+
if (this.#pending.size >= this.#maxOperations)
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Operation resource limit reached (${String(this.#maxOperations)} pending operations)`,
|
|
136
|
+
);
|
|
137
|
+
const id = randomUUID();
|
|
138
|
+
const startedAt = this.#now();
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const promise = Promise.resolve().then(() => run(controller.signal, id));
|
|
141
|
+
const entry: OperationEntry<T> = {
|
|
142
|
+
id,
|
|
143
|
+
startedAt,
|
|
144
|
+
deadlineAt: startedAt + this.#deadlineMs,
|
|
145
|
+
leaseExpiresAt: startedAt + this.#leaseMs,
|
|
146
|
+
metadata,
|
|
147
|
+
controller,
|
|
148
|
+
promise,
|
|
149
|
+
state: "running",
|
|
150
|
+
settled: false,
|
|
151
|
+
retainedAt: undefined,
|
|
152
|
+
waiters: new Set(),
|
|
153
|
+
timer: undefined,
|
|
154
|
+
leaseTimer: undefined,
|
|
155
|
+
};
|
|
156
|
+
this.#operations.set(id, entry);
|
|
157
|
+
this.#pending.add(promise);
|
|
158
|
+
void promise.finally(() => this.#pending.delete(promise)).catch(() => undefined);
|
|
159
|
+
entry.timer = setTimeout(() => {
|
|
160
|
+
this.#finishFailure(entry, "Operation deadline exceeded");
|
|
161
|
+
controller.abort(new Error("Operation deadline exceeded"));
|
|
162
|
+
}, this.#deadlineMs);
|
|
163
|
+
entry.timer.unref?.();
|
|
164
|
+
this.#armLease(entry);
|
|
165
|
+
void entry.promise.then(
|
|
166
|
+
(result) => {
|
|
167
|
+
entry.settled = true;
|
|
168
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
169
|
+
entry.timer = undefined;
|
|
170
|
+
if (entry.state === "running") {
|
|
171
|
+
entry.state = "complete";
|
|
172
|
+
entry.result = result;
|
|
173
|
+
this.#retainResult(entry);
|
|
174
|
+
this.#notify(entry);
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
},
|
|
178
|
+
(error: unknown) => {
|
|
179
|
+
entry.settled = true;
|
|
180
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
181
|
+
entry.timer = undefined;
|
|
182
|
+
if (entry.state === "running") {
|
|
183
|
+
entry.state = controller.signal.aborted ? "cancelled" : "failed";
|
|
184
|
+
entry.error = error;
|
|
185
|
+
this.#retainResult(entry);
|
|
186
|
+
this.#notify(entry);
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
return { ...snapshot(entry), promise: entry.promise };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
get(operationId: string): OperationSnapshot<T> {
|
|
195
|
+
this.#expireRetained();
|
|
196
|
+
const entry = this.#operations.get(operationId);
|
|
197
|
+
if (!entry) throw new Error("Operation was not found or has expired; start the query again");
|
|
198
|
+
return snapshot(entry);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
updateProgress(operationId: string, progress: OperationProgress): void {
|
|
202
|
+
const entry = this.#operations.get(operationId);
|
|
203
|
+
if (!entry || entry.state !== "running") return;
|
|
204
|
+
entry.progress = { ...progress };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
touch(operationId: string): OperationSnapshot<T> {
|
|
208
|
+
const entry = this.#operations.get(operationId);
|
|
209
|
+
if (!entry) throw new Error("Operation was not found or has expired; start the query again");
|
|
210
|
+
if (entry.state === "running") this.#touch(entry);
|
|
211
|
+
return snapshot(entry);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async wait(
|
|
215
|
+
operationId: string,
|
|
216
|
+
timeoutMs = OPERATION_INITIAL_WAIT_MS,
|
|
217
|
+
signal?: AbortSignal,
|
|
218
|
+
): Promise<OperationWaitResult<T>> {
|
|
219
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0)
|
|
220
|
+
throw new Error("timeoutMs must be a non-negative safe integer");
|
|
221
|
+
const entry = this.#operations.get(operationId);
|
|
222
|
+
if (!entry) throw new Error("Operation was not found or has expired; start the query again");
|
|
223
|
+
if (entry.state !== "running") return this.#settled(entry);
|
|
224
|
+
this.#touch(entry);
|
|
225
|
+
return new Promise<OperationWaitResult<T>>((resolve, reject) => {
|
|
226
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
227
|
+
let finished = false;
|
|
228
|
+
let aborting = false;
|
|
229
|
+
const onSettled = (): void => finish(this.#settled(entry));
|
|
230
|
+
const finish = (result: OperationWaitResult<T>): void => {
|
|
231
|
+
if (finished || aborting) return;
|
|
232
|
+
finished = true;
|
|
233
|
+
if (timer) clearTimeout(timer);
|
|
234
|
+
signal?.removeEventListener("abort", onAbort);
|
|
235
|
+
entry.waiters.delete(onSettled);
|
|
236
|
+
resolve(result);
|
|
237
|
+
};
|
|
238
|
+
const onAbort = (): void => {
|
|
239
|
+
if (finished || aborting) return;
|
|
240
|
+
aborting = true;
|
|
241
|
+
if (timer) clearTimeout(timer);
|
|
242
|
+
signal?.removeEventListener("abort", onAbort);
|
|
243
|
+
entry.waiters.delete(onSettled);
|
|
244
|
+
const reason = abortReason(signal);
|
|
245
|
+
void this.cancelAndWait(operationId, reason).then(
|
|
246
|
+
() => {
|
|
247
|
+
if (!finished) {
|
|
248
|
+
finished = true;
|
|
249
|
+
reject(reason);
|
|
250
|
+
}
|
|
251
|
+
return undefined;
|
|
252
|
+
},
|
|
253
|
+
(error: unknown) => {
|
|
254
|
+
if (!finished) {
|
|
255
|
+
finished = true;
|
|
256
|
+
reject(error);
|
|
257
|
+
}
|
|
258
|
+
return undefined;
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
};
|
|
262
|
+
if (signal?.aborted) {
|
|
263
|
+
onAbort();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
267
|
+
timer = setTimeout(() => finish({ state: "running", operation: snapshot(entry) }), timeoutMs);
|
|
268
|
+
timer.unref?.();
|
|
269
|
+
entry.waiters.add(onSettled);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
cancel(operationId: string, reason = new Error("Operation cancelled")): OperationSnapshot<T> {
|
|
274
|
+
const entry = this.#operations.get(operationId);
|
|
275
|
+
if (!entry) throw new Error("Operation was not found or has expired; start the query again");
|
|
276
|
+
if (entry.state === "running") {
|
|
277
|
+
entry.state = "cancelled";
|
|
278
|
+
entry.error = reason;
|
|
279
|
+
entry.controller.abort(reason);
|
|
280
|
+
this.#retainResult(entry);
|
|
281
|
+
this.#notify(entry);
|
|
282
|
+
}
|
|
283
|
+
return snapshot(entry);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async cancelAndWait(
|
|
287
|
+
operationId: string,
|
|
288
|
+
reason = new Error("Operation cancelled"),
|
|
289
|
+
): Promise<OperationSnapshot<T>> {
|
|
290
|
+
const entry = this.#operations.get(operationId);
|
|
291
|
+
if (!entry) throw new Error("Operation was not found or has expired; start the query again");
|
|
292
|
+
this.cancel(operationId, reason);
|
|
293
|
+
await Promise.allSettled([entry.promise]);
|
|
294
|
+
return snapshot(entry);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
clear(reason = new Error("Operation session closed")): void {
|
|
298
|
+
this.#closed = true;
|
|
299
|
+
for (const entry of this.#operations.values()) {
|
|
300
|
+
if (entry.state === "running") this.cancel(entry.id, reason);
|
|
301
|
+
this.#clearTimers(entry);
|
|
302
|
+
}
|
|
303
|
+
this.#operations.clear();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
reset(reason = new Error("Operation session reset")): void {
|
|
307
|
+
this.clear(reason);
|
|
308
|
+
this.#closed = false;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async shutdown(reason = new Error("Operation session closed")): Promise<void> {
|
|
312
|
+
this.clear(reason);
|
|
313
|
+
await Promise.allSettled(this.#pending);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#settled(entry: OperationEntry<T>): OperationWaitResult<T> {
|
|
317
|
+
const current = snapshot(entry);
|
|
318
|
+
if (entry.state === "complete" && entry.result !== undefined)
|
|
319
|
+
return { state: "complete", operation: current, result: entry.result };
|
|
320
|
+
if (entry.state === "failed")
|
|
321
|
+
return { state: "failed", operation: current, error: entry.error };
|
|
322
|
+
if (entry.state === "cancelled" || entry.state === "expired")
|
|
323
|
+
return { state: entry.state, operation: current, error: entry.error };
|
|
324
|
+
return { state: "running", operation: current };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
#finishFailure(entry: OperationEntry<T>, message: string): void {
|
|
328
|
+
if (entry.state !== "running") return;
|
|
329
|
+
entry.state = "failed";
|
|
330
|
+
entry.error = new Error(message);
|
|
331
|
+
this.#retainResult(entry);
|
|
332
|
+
this.#notify(entry);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#retainResult(entry: OperationEntry<T>): void {
|
|
336
|
+
if (entry.settled && entry.timer) clearTimeout(entry.timer);
|
|
337
|
+
if (entry.settled) entry.timer = undefined;
|
|
338
|
+
if (entry.leaseTimer) clearTimeout(entry.leaseTimer);
|
|
339
|
+
entry.leaseTimer = undefined;
|
|
340
|
+
entry.retainedAt = this.#now();
|
|
341
|
+
entry.leaseExpiresAt = this.#now() + this.#resultTtlMs;
|
|
342
|
+
entry.leaseTimer = setTimeout(() => this.#evict(entry), this.#resultTtlMs);
|
|
343
|
+
entry.leaseTimer.unref?.();
|
|
344
|
+
this.#evictRetainedOverflow();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
#notify(entry: OperationEntry<T>): void {
|
|
348
|
+
const waiters = [...entry.waiters];
|
|
349
|
+
entry.waiters.clear();
|
|
350
|
+
for (const waiter of waiters) waiter();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
#touch(entry: OperationEntry<T>): void {
|
|
354
|
+
entry.leaseExpiresAt = this.#now() + this.#leaseMs;
|
|
355
|
+
this.#armLease(entry);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#armLease(entry: OperationEntry<T>): void {
|
|
359
|
+
if (entry.leaseTimer) clearTimeout(entry.leaseTimer);
|
|
360
|
+
entry.leaseTimer = setTimeout(
|
|
361
|
+
() => {
|
|
362
|
+
if (entry.state === "running") {
|
|
363
|
+
entry.state = "expired";
|
|
364
|
+
entry.error = new Error("Operation lease expired without a continuation");
|
|
365
|
+
entry.controller.abort(entry.error);
|
|
366
|
+
}
|
|
367
|
+
this.#evict(entry);
|
|
368
|
+
},
|
|
369
|
+
Math.max(1, entry.leaseExpiresAt - this.#now()),
|
|
370
|
+
);
|
|
371
|
+
entry.leaseTimer.unref?.();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#evict(entry: OperationEntry<T>): void {
|
|
375
|
+
if (this.#operations.get(entry.id) !== entry) return;
|
|
376
|
+
if (entry.state === "running") {
|
|
377
|
+
entry.state = "expired";
|
|
378
|
+
entry.error = new Error("Operation lease expired without a continuation");
|
|
379
|
+
entry.controller.abort(entry.error);
|
|
380
|
+
}
|
|
381
|
+
this.#notify(entry);
|
|
382
|
+
this.#clearTimers(entry);
|
|
383
|
+
this.#operations.delete(entry.id);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
#evictRetainedOverflow(): void {
|
|
387
|
+
const retained = [...this.#operations.values()]
|
|
388
|
+
.filter((entry) => entry.state !== "running" && entry.retainedAt !== undefined)
|
|
389
|
+
.toSorted(
|
|
390
|
+
(left, right) =>
|
|
391
|
+
(left.retainedAt ?? left.startedAt) - (right.retainedAt ?? right.startedAt),
|
|
392
|
+
);
|
|
393
|
+
while (retained.length > this.#maxRetainedOperations) {
|
|
394
|
+
const candidate = retained.shift();
|
|
395
|
+
if (!candidate || candidate.waiters.size > 0) continue;
|
|
396
|
+
this.#evict(candidate);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
#clearTimers(entry: OperationEntry<T>, includeLease = true): void {
|
|
401
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
402
|
+
if (includeLease && entry.leaseTimer) clearTimeout(entry.leaseTimer);
|
|
403
|
+
entry.timer = undefined;
|
|
404
|
+
if (includeLease) entry.leaseTimer = undefined;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
#expireRetained(): void {
|
|
408
|
+
const now = this.#now();
|
|
409
|
+
for (const entry of this.#operations.values()) {
|
|
410
|
+
if (entry.leaseExpiresAt <= now) this.#evict(entry);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { SiftLightInput } from "./service.js";
|
|
2
|
+
import type { OperationDetails, SiftLightDetails, SiftLightResult } from "./types.js";
|
|
3
|
+
import type { OperationSnapshot, OperationWaitResult } from "./operation-lifecycle.js";
|
|
4
|
+
|
|
5
|
+
export function operationRequest(operationId: string): SiftLightInput {
|
|
6
|
+
return { mode: "await", operationId };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function operationDetails<T>(
|
|
10
|
+
operation: OperationSnapshot<T>,
|
|
11
|
+
nextRequest?: SiftLightInput,
|
|
12
|
+
): OperationDetails {
|
|
13
|
+
return {
|
|
14
|
+
id: operation.id,
|
|
15
|
+
mode: operation.metadata.mode,
|
|
16
|
+
state: operation.state,
|
|
17
|
+
startedAt: operation.startedAt,
|
|
18
|
+
deadlineAt: operation.deadlineAt,
|
|
19
|
+
leaseExpiresAt: operation.leaseExpiresAt,
|
|
20
|
+
...(operation.progress ? { progress: operation.progress } : {}),
|
|
21
|
+
...(nextRequest ? { nextRequest } : {}),
|
|
22
|
+
...(operation.error instanceof Error ? { error: operation.error.message } : {}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function waitingResult(
|
|
27
|
+
operation: OperationSnapshot<SiftLightResult>,
|
|
28
|
+
mode: "concept" | "hybrid",
|
|
29
|
+
): SiftLightResult {
|
|
30
|
+
const nextRequest = operationRequest(operation.id);
|
|
31
|
+
const progress = operation.progress ? ` Progress: ${JSON.stringify(operation.progress)}.` : "";
|
|
32
|
+
const details: SiftLightDetails = {
|
|
33
|
+
version: 1,
|
|
34
|
+
mode,
|
|
35
|
+
status: "waiting",
|
|
36
|
+
totalMatches: 0,
|
|
37
|
+
storedMatches: 0,
|
|
38
|
+
totalFiles: 0,
|
|
39
|
+
returnedMatches: 0,
|
|
40
|
+
snapshotComplete: false,
|
|
41
|
+
nextRequest,
|
|
42
|
+
...(operation.metadata.redact ? { redactionRequested: true } : {}),
|
|
43
|
+
operation: operationDetails(operation, nextRequest),
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
text: `Operation ${operation.id} is still running; no evidence page is available yet.${progress}\n\nNext request: ${JSON.stringify(nextRequest)}. Copy it exactly to continue waiting; the original query will not be started again.`,
|
|
47
|
+
details,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function operationStateResult(
|
|
52
|
+
operation: OperationSnapshot<SiftLightResult>,
|
|
53
|
+
mode: "concept" | "hybrid",
|
|
54
|
+
): SiftLightResult {
|
|
55
|
+
if (operation.state === "complete" && operation.result !== undefined)
|
|
56
|
+
return completeOperationResult(operation.result, operation);
|
|
57
|
+
const details: SiftLightDetails = {
|
|
58
|
+
version: 1,
|
|
59
|
+
mode,
|
|
60
|
+
status: operation.state,
|
|
61
|
+
totalMatches: 0,
|
|
62
|
+
storedMatches: 0,
|
|
63
|
+
totalFiles: 0,
|
|
64
|
+
returnedMatches: 0,
|
|
65
|
+
snapshotComplete: false,
|
|
66
|
+
...(operation.metadata.redact ? { redactionRequested: true } : {}),
|
|
67
|
+
operation: operationDetails(operation),
|
|
68
|
+
};
|
|
69
|
+
const message = operation.error instanceof Error ? ` ${operation.error.message}.` : "";
|
|
70
|
+
return {
|
|
71
|
+
text: `Operation ${operation.id} ${operation.state}.${message}`,
|
|
72
|
+
details,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function completeOperationResult(
|
|
77
|
+
result: SiftLightResult,
|
|
78
|
+
operation: OperationSnapshot<SiftLightResult>,
|
|
79
|
+
): SiftLightResult {
|
|
80
|
+
const stable = structuredClone(result);
|
|
81
|
+
return {
|
|
82
|
+
...stable,
|
|
83
|
+
text: `${stable.text}\n\nOperation ${operation.id} complete; final result is retained for stable await re-fetch.`,
|
|
84
|
+
details: {
|
|
85
|
+
...stable.details,
|
|
86
|
+
...(operation.metadata.redact ? { redactionRequested: true } : {}),
|
|
87
|
+
operation: operationDetails(operation),
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function operationOutcome(
|
|
93
|
+
outcome: OperationWaitResult<SiftLightResult>,
|
|
94
|
+
mode: "concept" | "hybrid",
|
|
95
|
+
): SiftLightResult {
|
|
96
|
+
if (outcome.state === "running") return waitingResult(outcome.operation, mode);
|
|
97
|
+
if (outcome.state === "complete")
|
|
98
|
+
return completeOperationResult(outcome.result, outcome.operation);
|
|
99
|
+
throw outcome.error;
|
|
100
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** The group owns every operation until it settles, including failure-triggered cancellation. */
|
|
2
|
+
export async function runOwnedParallel<T>(
|
|
3
|
+
start: (signal: AbortSignal) => Promise<T>[],
|
|
4
|
+
parent?: AbortSignal,
|
|
5
|
+
): Promise<T[]> {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const signal = parent ? AbortSignal.any([parent, controller.signal]) : controller.signal;
|
|
8
|
+
const operations = start(signal).map(async (operation) => {
|
|
9
|
+
try {
|
|
10
|
+
return await operation;
|
|
11
|
+
} catch (error) {
|
|
12
|
+
controller.abort();
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
try {
|
|
17
|
+
return await Promise.all(operations);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
await Promise.allSettled(operations);
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { abortError, SiftLightError } from "./errors.js";
|
|
3
|
+
import type { Writable } from "node:stream";
|
|
4
|
+
|
|
5
|
+
const MAX_STDERR_BYTES = 16 * 1024;
|
|
6
|
+
const TERMINATE_GRACE_MS = 250;
|
|
7
|
+
const TERMINATE_DEADLINE_MS = 2_000;
|
|
8
|
+
|
|
9
|
+
interface OwnedProcessOptions {
|
|
10
|
+
executable: string;
|
|
11
|
+
args: string[];
|
|
12
|
+
cwd: string;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
env?: NodeJS.ProcessEnv;
|
|
15
|
+
input?: Uint8Array;
|
|
16
|
+
interactive?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Own the child until its streams close, including failed protocol consumers. */
|
|
20
|
+
export async function runOwnedProcess(
|
|
21
|
+
options: OwnedProcessOptions,
|
|
22
|
+
consumeOutput: (stdout: AsyncIterable<Uint8Array>, stdin: Writable | null) => Promise<void>,
|
|
23
|
+
): Promise<{ code: number | null; stderr: string }> {
|
|
24
|
+
const { executable, args, cwd, signal, env, input } = options;
|
|
25
|
+
if (signal?.aborted) throw abortError();
|
|
26
|
+
const spawnOptions = { cwd, windowsHide: true, ...(env ? { env } : {}) };
|
|
27
|
+
const child =
|
|
28
|
+
input === undefined && !options.interactive
|
|
29
|
+
? spawn(executable, args, { ...spawnOptions, stdio: ["ignore", "pipe", "pipe"] })
|
|
30
|
+
: spawn(executable, args, { ...spawnOptions, stdio: ["pipe", "pipe", "pipe"] });
|
|
31
|
+
// Interactive writes reject their callbacks; keep stream errors observed until child closure.
|
|
32
|
+
if (options.interactive) child.stdin?.on("error", () => undefined);
|
|
33
|
+
const inputComplete = new Promise<void>((resolveInput, rejectInput) => {
|
|
34
|
+
if (input === undefined || child.stdin === null) {
|
|
35
|
+
resolveInput();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
child.stdin.on("error", rejectInput);
|
|
39
|
+
child.stdin.end(input, (error?: Error | null) => {
|
|
40
|
+
if (error) rejectInput(error);
|
|
41
|
+
else resolveInput();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
let closed = false;
|
|
45
|
+
let spawnError: Error | undefined;
|
|
46
|
+
let forceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
47
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
48
|
+
let rejectClose: ((error: Error) => void) | undefined;
|
|
49
|
+
const closePromise = new Promise<number | null>((resolveClose, reject) => {
|
|
50
|
+
rejectClose = reject;
|
|
51
|
+
child.once("error", (error) => {
|
|
52
|
+
spawnError = error;
|
|
53
|
+
});
|
|
54
|
+
child.once("close", (code) => {
|
|
55
|
+
closed = true;
|
|
56
|
+
resolveClose(code);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
const stderrChunks: Buffer[] = [];
|
|
60
|
+
let stderrBytes = 0;
|
|
61
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
62
|
+
const retained = chunk.subarray(0, MAX_STDERR_BYTES - stderrBytes);
|
|
63
|
+
if (retained.length === 0) return;
|
|
64
|
+
stderrChunks.push(retained);
|
|
65
|
+
stderrBytes += retained.length;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const terminate = () => {
|
|
69
|
+
if (closed || forceTimer) return;
|
|
70
|
+
child.stdin?.destroy();
|
|
71
|
+
child.kill("SIGTERM");
|
|
72
|
+
forceTimer = setTimeout(() => {
|
|
73
|
+
if (!closed) child.kill("SIGKILL");
|
|
74
|
+
}, TERMINATE_GRACE_MS);
|
|
75
|
+
deadlineTimer = setTimeout(() => {
|
|
76
|
+
rejectClose?.(new SiftLightError("Owned search process did not close after termination"));
|
|
77
|
+
}, TERMINATE_DEADLINE_MS);
|
|
78
|
+
};
|
|
79
|
+
signal?.addEventListener("abort", terminate, { once: true });
|
|
80
|
+
if (signal?.aborted) terminate();
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const [code] = await Promise.all([
|
|
84
|
+
closePromise,
|
|
85
|
+
consumeOutput(child.stdout, child.stdin),
|
|
86
|
+
inputComplete,
|
|
87
|
+
]);
|
|
88
|
+
if (signal?.aborted) throw abortError();
|
|
89
|
+
if (spawnError) throw spawnError;
|
|
90
|
+
return { code, stderr: Buffer.concat(stderrChunks).toString("utf8") };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
terminate();
|
|
93
|
+
await closePromise;
|
|
94
|
+
if (signal?.aborted) throw abortError();
|
|
95
|
+
if (spawnError) throw spawnError;
|
|
96
|
+
throw error;
|
|
97
|
+
} finally {
|
|
98
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
99
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
100
|
+
signal?.removeEventListener("abort", terminate);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { abortError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/** Process-wide heavy-provider admission. Cancelling queued work does not overtake its predecessor. */
|
|
4
|
+
export class OwnedTaskQueue {
|
|
5
|
+
#tail: Promise<void> = Promise.resolve();
|
|
6
|
+
async acquire(signal?: AbortSignal): Promise<() => void> {
|
|
7
|
+
if (signal?.aborted) throw abortError();
|
|
8
|
+
const previous = this.#tail;
|
|
9
|
+
const completed = Promise.withResolvers<void>();
|
|
10
|
+
this.#tail = previous.then(() => completed.promise);
|
|
11
|
+
const cancelled = Promise.withResolvers<never>();
|
|
12
|
+
const abort = () => cancelled.reject(abortError());
|
|
13
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
14
|
+
if (signal?.aborted) abort();
|
|
15
|
+
try {
|
|
16
|
+
await Promise.race([previous, cancelled.promise]);
|
|
17
|
+
if (signal?.aborted) throw abortError();
|
|
18
|
+
let released = false;
|
|
19
|
+
return () => {
|
|
20
|
+
if (released) return;
|
|
21
|
+
released = true;
|
|
22
|
+
completed.resolve();
|
|
23
|
+
};
|
|
24
|
+
} finally {
|
|
25
|
+
signal?.removeEventListener("abort", abort);
|
|
26
|
+
if (signal?.aborted) completed.resolve();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async run<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
31
|
+
const release = await this.acquire(signal);
|
|
32
|
+
try {
|
|
33
|
+
return await operation();
|
|
34
|
+
} finally {
|
|
35
|
+
release();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|