@cassiomc1/forgeloop 1.10.1 → 1.10.2

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.
@@ -0,0 +1,245 @@
1
+ import path from "node:path";
2
+
3
+ import { assertSafePath } from "../../core/filesystem.js";
4
+ import {
5
+ normalizeAdvisoryRecallOptions,
6
+ } from "../../core/advisory-context/constants.js";
7
+ import {
8
+ E_ADVISORY_CONTEXT_PROVIDER_INVALID,
9
+ E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE,
10
+ E_ADVISORY_CONTEXT_RESULT_INVALID,
11
+ E_ADVISORY_CONTEXT_TIMEOUT,
12
+ } from "../../core/error-codes.js";
13
+ import {
14
+ RIPWIRE_PROCESS_LIMITS,
15
+ parseRipwireVersion,
16
+ runRipwireCommand,
17
+ } from "./process.js";
18
+ import {
19
+ extractCandidateRows,
20
+ getRipwireRowPath,
21
+ normalizeReportedPath,
22
+ normalizeRipwireResult,
23
+ } from "./normalize.js";
24
+
25
+ function providerError(code, message) {
26
+ const error = new Error(message);
27
+ error.name = "RipwireAdvisoryProviderError";
28
+ error.code = code;
29
+ return error;
30
+ }
31
+
32
+ function assertAbsoluteExecutablePath(value) {
33
+ if (typeof value !== "string" || value.trim() === "" || !path.isAbsolute(value) || /\p{Cc}/u.test(value)) {
34
+ throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire executablePath must be an absolute portable path");
35
+ }
36
+ return value;
37
+ }
38
+
39
+ function assertExpectedVersion(value) {
40
+ if (
41
+ typeof value !== "string"
42
+ || value.trim() === ""
43
+ || value.length > 64
44
+ || !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/u.test(value)
45
+ ) {
46
+ throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire expectedVersion must be a qualified version token under 64 characters");
47
+ }
48
+ return value;
49
+ }
50
+
51
+ function assertProcessLimit(value, maximum, label) {
52
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
53
+ throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, `${label} must be an integer between 1 and ${maximum}`);
54
+ }
55
+ return value;
56
+ }
57
+
58
+ function remainingTimeout(deadline) {
59
+ const remaining = deadline - Date.now();
60
+ if (remaining < 1) {
61
+ throw providerError(E_ADVISORY_CONTEXT_TIMEOUT, "Ripwire advisory recall deadline expired");
62
+ }
63
+ return remaining;
64
+ }
65
+
66
+ function reportTransport(callback, payload) {
67
+ if (typeof callback !== "function") return;
68
+ try {
69
+ callback(Object.freeze({ ...payload }));
70
+ } catch {
71
+ // Observability hooks cannot change advisory correctness or failure mapping.
72
+ }
73
+ }
74
+
75
+ async function runAndReport(executablePath, args, {
76
+ cwd,
77
+ timeoutMs,
78
+ spawnImpl,
79
+ env,
80
+ maxStdoutBytes,
81
+ maxStderrBytes,
82
+ onTransport,
83
+ kind,
84
+ } = {}) {
85
+ const result = await runRipwireCommand(executablePath, args, {
86
+ cwd,
87
+ timeoutMs,
88
+ spawnImpl,
89
+ env,
90
+ maxStdoutBytes,
91
+ maxStderrBytes,
92
+ });
93
+ reportTransport(onTransport, {
94
+ kind,
95
+ stdoutBytes: result.stdoutBytes,
96
+ stderrBytes: result.stderrBytes,
97
+ durationMs: result.durationMs,
98
+ });
99
+ return result;
100
+ }
101
+
102
+ function queryArguments(projectPath, query) {
103
+ return [
104
+ projectPath,
105
+ `--for=${query}`,
106
+ "--signatures-only",
107
+ "--json",
108
+ "--no-cache",
109
+ "--exclude=.forgeloop",
110
+ ];
111
+ }
112
+
113
+ /**
114
+ * Create an optional host-injected Ripwire adapter.
115
+ *
116
+ * Construction is inert: it validates the explicit executable/version pair
117
+ * but does not discover binaries, spawn processes, access the network, or
118
+ * write lifecycle state. Version qualification is repeated lazily before a
119
+ * query so a changed executable cannot silently change the provider identity.
120
+ */
121
+ export function createRipwireAdvisoryContextProvider({
122
+ executablePath,
123
+ expectedVersion,
124
+ spawnImpl,
125
+ env,
126
+ maxStdoutBytes = RIPWIRE_PROCESS_LIMITS.maxStdoutBytes,
127
+ maxStderrBytes = RIPWIRE_PROCESS_LIMITS.maxStderrBytes,
128
+ onTransport,
129
+ } = {}) {
130
+ const qualifiedExecutablePath = assertAbsoluteExecutablePath(executablePath);
131
+ const qualifiedExpectedVersion = assertExpectedVersion(expectedVersion);
132
+ const qualifiedStdoutLimit = assertProcessLimit(maxStdoutBytes, RIPWIRE_PROCESS_LIMITS.maxStdoutBytes, "Ripwire stdout limit");
133
+ const qualifiedStderrLimit = assertProcessLimit(maxStderrBytes, RIPWIRE_PROCESS_LIMITS.maxStderrBytes, "Ripwire stderr limit");
134
+
135
+ const provider = {
136
+ id: "ripwire",
137
+ version: qualifiedExpectedVersion,
138
+ async recall({
139
+ projectPath,
140
+ taskId,
141
+ query,
142
+ limit,
143
+ maxItemChars,
144
+ maxTotalChars,
145
+ timeoutMs,
146
+ } = {}) {
147
+ if (typeof projectPath !== "string" || !path.isAbsolute(projectPath) || /\p{Cc}/u.test(projectPath)) {
148
+ throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire projectPath must be an absolute path");
149
+ }
150
+ if (typeof taskId !== "string" || taskId.trim() === "") {
151
+ throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire taskId must be a non-empty string");
152
+ }
153
+ if (typeof query !== "string" || query.trim() === "" || /\p{Cc}/u.test(query)) {
154
+ throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire query must be a non-empty portable string");
155
+ }
156
+ const options = normalizeAdvisoryRecallOptions({ limit, maxItemChars, maxTotalChars, timeoutMs });
157
+ const projectRoot = path.resolve(projectPath);
158
+ try {
159
+ await assertSafePath(projectRoot, ".");
160
+ } catch {
161
+ throw providerError(E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE, "Ripwire target directory is unavailable or unsafe");
162
+ }
163
+
164
+ const deadline = Date.now() + options.timeoutMs;
165
+ let versionResult;
166
+ try {
167
+ versionResult = await runAndReport(qualifiedExecutablePath, ["--version"], {
168
+ cwd: projectRoot,
169
+ timeoutMs: remainingTimeout(deadline),
170
+ spawnImpl,
171
+ env,
172
+ maxStdoutBytes: Math.min(qualifiedStdoutLimit, 64 * 1024),
173
+ maxStderrBytes: qualifiedStderrLimit,
174
+ onTransport,
175
+ kind: "version",
176
+ });
177
+ } catch (error) {
178
+ throw error.code ? error : providerError(E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE, "Ripwire version probe failed");
179
+ }
180
+
181
+ let actualVersion;
182
+ try {
183
+ actualVersion = parseRipwireVersion(versionResult.stdout);
184
+ } catch {
185
+ throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire version probe was not qualified");
186
+ }
187
+ if (actualVersion !== qualifiedExpectedVersion) {
188
+ throw providerError(
189
+ E_ADVISORY_CONTEXT_PROVIDER_INVALID,
190
+ "Ripwire executable version does not match the expected qualified version",
191
+ );
192
+ }
193
+
194
+ let queryResult;
195
+ try {
196
+ queryResult = await runAndReport(qualifiedExecutablePath, queryArguments(projectRoot, query), {
197
+ cwd: projectRoot,
198
+ timeoutMs: remainingTimeout(deadline),
199
+ spawnImpl,
200
+ env,
201
+ maxStdoutBytes: qualifiedStdoutLimit,
202
+ maxStderrBytes: qualifiedStderrLimit,
203
+ onTransport,
204
+ kind: "query",
205
+ });
206
+ } catch (error) {
207
+ throw error.code ? error : providerError(E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE, "Ripwire query failed");
208
+ }
209
+
210
+ let raw;
211
+ try {
212
+ raw = JSON.parse(queryResult.stdout);
213
+ } catch {
214
+ throw providerError(E_ADVISORY_CONTEXT_RESULT_INVALID, "Ripwire query did not return valid JSON");
215
+ }
216
+
217
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
218
+ throw providerError(E_ADVISORY_CONTEXT_RESULT_INVALID, "Ripwire query JSON root must be an object");
219
+ }
220
+
221
+ const safeSourcePaths = new Set();
222
+ const rows = extractCandidateRows(raw) ?? [];
223
+ for (const row of rows) {
224
+ const relativePath = normalizeReportedPath(getRipwireRowPath(row), projectRoot);
225
+ if (!relativePath) continue;
226
+ try {
227
+ await assertSafePath(projectRoot, relativePath);
228
+ safeSourcePaths.add(relativePath);
229
+ } catch {
230
+ // The normalizer will disclose and drop this candidate.
231
+ }
232
+ }
233
+
234
+ return normalizeRipwireResult(raw, {
235
+ projectPath: projectRoot,
236
+ ...options,
237
+ sourcePathValidator: (relativePath) => safeSourcePaths.has(relativePath),
238
+ });
239
+ },
240
+ };
241
+
242
+ return Object.freeze(provider);
243
+ }
244
+
245
+ export { queryArguments };
@@ -20,6 +20,8 @@ import {
20
20
  E_PORTABLE_CONTEXT_INVALID,
21
21
  } from "../error-codes.js";
22
22
 
23
+ const PROVIDER_CLEANUP_GRACE_MS = 1000;
24
+
23
25
  function serviceError(code, message, cause) {
24
26
  const error = new Error(message, cause !== undefined ? { cause } : undefined);
25
27
  error.name = "AdvisoryContextServiceError";
@@ -106,30 +108,50 @@ export async function recallAdvisoryContext({
106
108
  }
107
109
 
108
110
  let timer;
111
+ let cleanupTimer;
112
+ const timeoutError = serviceError(
113
+ E_ADVISORY_CONTEXT_TIMEOUT,
114
+ `Advisory recall from provider "${providerName}" timed out after ${effectiveOptions.timeoutMs}ms`,
115
+ );
116
+ const recallPromise = Promise.resolve(
117
+ provider.recall({
118
+ projectPath: path.resolve(target),
119
+ taskId,
120
+ query: normalizedQuery,
121
+ ...effectiveOptions,
122
+ }),
123
+ );
124
+ let timedOut = false;
125
+ const guardedRecallPromise = recallPromise.then(
126
+ (value) => timedOut ? new Promise(() => {}) : value,
127
+ (error) => {
128
+ if (timedOut) return new Promise(() => {});
129
+ throw error;
130
+ },
131
+ );
109
132
  const timeoutPromise = new Promise((_, reject) => {
110
133
  timer = setTimeout(() => {
111
- reject(
112
- serviceError(
113
- E_ADVISORY_CONTEXT_TIMEOUT,
114
- `Advisory recall from provider "${providerName}" timed out after ${effectiveOptions.timeoutMs}ms`,
115
- ),
134
+ timedOut = true;
135
+ cleanupTimer = setTimeout(() => reject(timeoutError), PROVIDER_CLEANUP_GRACE_MS);
136
+ recallPromise.then(
137
+ () => {
138
+ clearTimeout(cleanupTimer);
139
+ reject(timeoutError);
140
+ },
141
+ () => {
142
+ clearTimeout(cleanupTimer);
143
+ reject(timeoutError);
144
+ },
116
145
  );
117
146
  }, effectiveOptions.timeoutMs);
118
147
  });
119
148
 
120
149
  let rawResult;
121
150
  try {
122
- const recallPromise = Promise.resolve(
123
- provider.recall({
124
- projectPath: path.resolve(target),
125
- taskId,
126
- query: normalizedQuery,
127
- ...effectiveOptions,
128
- }),
129
- );
130
- rawResult = await Promise.race([recallPromise, timeoutPromise]);
151
+ rawResult = await Promise.race([guardedRecallPromise, timeoutPromise]);
131
152
  } finally {
132
153
  clearTimeout(timer);
154
+ clearTimeout(cleanupTimer);
133
155
  }
134
156
 
135
157
  return normalizeAdvisoryContextResult(rawResult, {
@@ -126,7 +126,7 @@ export async function acceptCanonicalHandoff(target, {
126
126
  assertPortableContextSafe(normalizedHarness, { label: "harness" });
127
127
  }
128
128
 
129
- return withTaskTransaction(
129
+ const runAcceptance = () => withTaskTransaction(
130
130
  {
131
131
  target,
132
132
  taskId,
@@ -274,4 +274,13 @@ export async function acceptCanonicalHandoff(target, {
274
274
  };
275
275
  },
276
276
  );
277
+
278
+ for (let attempt = 0; attempt < 3; attempt += 1) {
279
+ try {
280
+ return await runAcceptance();
281
+ } catch (error) {
282
+ if (error?.code !== "E_TASK_LOCKED" || attempt === 2) throw error;
283
+ await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
284
+ }
285
+ }
277
286
  }
@@ -307,12 +307,21 @@ export async function mutateWorkState(target, { expectedRevision, packageRoot =
307
307
  throw error;
308
308
  }
309
309
  if (!(await getTaskTransaction(target))) {
310
- return withTaskTransaction({
311
- target,
312
- taskId: taskId ?? "legacy-work-state",
313
- lockTaskId: taskId ?? "legacy-work-state",
314
- operation: "mutate-work-state",
315
- }, async () => mutateWorkState(target, { expectedRevision, packageRoot, taskId, statePath }, updater));
310
+ try {
311
+ return await withTaskTransaction({
312
+ target,
313
+ taskId: taskId ?? "legacy-work-state",
314
+ lockTaskId: taskId ?? "legacy-work-state",
315
+ operation: "mutate-work-state",
316
+ }, async () => mutateWorkState(target, { expectedRevision, packageRoot, taskId, statePath }, updater));
317
+ } catch (error) {
318
+ if (error?.code === "E_TASK_LOCKED") {
319
+ const revisionError = new WorkStateError("Work state revision is being mutated concurrently");
320
+ revisionError.code = "E_STATE_REVISION_CONFLICT";
321
+ throw revisionError;
322
+ }
323
+ throw error;
324
+ }
316
325
  }
317
326
  const current = await readWorkState(target, { packageRoot, taskId, statePath });
318
327
  if (!current || (current.revision ?? 0) !== expectedRevision) {
@@ -108,6 +108,14 @@ export interface ForgeLoopAdvisoryContextProvider {
108
108
 
109
109
  export type ForgeLoopAdvisoryContextProviderFactory = () => ForgeLoopAdvisoryContextProvider | Promise<ForgeLoopAdvisoryContextProvider>;
110
110
 
111
+ /** Options for the optional, host-injected Ripwire advisory adapter. */
112
+ export interface ForgeLoopRipwireProviderOptions {
113
+ /** Absolute path selected by the host; ForgeLoop does not discover it. */
114
+ executablePath: string;
115
+ /** Exact version string qualified by the host and checked lazily at recall time. */
116
+ expectedVersion: string;
117
+ }
118
+
111
119
  export interface ForgeLoopNormalizedAdvisoryContextResult {
112
120
  provider: {
113
121
  id: string;
@@ -269,6 +277,9 @@ export declare function recallAdvisoryContext(input: {
269
277
  timeoutMs?: number;
270
278
  runtimeContext?: ForgeLoopContext | Record<string, unknown>;
271
279
  }): Promise<ForgeLoopNormalizedAdvisoryContextResult>;
280
+ export declare function createRipwireAdvisoryContextProvider(
281
+ options: ForgeLoopRipwireProviderOptions,
282
+ ): ForgeLoopAdvisoryContextProvider;
272
283
  export declare const ADVISORY_CONTEXT_LIMITS: Readonly<Record<string, number>>;
273
284
  export declare const ADVISORY_CONTEXT_TRUST: Readonly<Record<string, unknown>>;
274
285
  export declare function normalizeAdvisoryRecallOptions(input?: Partial<ForgeLoopAdvisoryRecallOptions>): ForgeLoopAdvisoryRecallOptions;
@@ -81,6 +81,7 @@ export {
81
81
  VERIFICATION_ISOLATION_MODES,
82
82
  } from "./core/verification-execution.js";
83
83
  export { recallAdvisoryContext } from "./core/advisory-context/service.js";
84
+ export { createRipwireAdvisoryContextProvider } from "./adapters/ripwire/provider.js";
84
85
  export {
85
86
  ADVISORY_CONTEXT_LIMITS,
86
87
  ADVISORY_CONTEXT_TRUST,