@cassiomc1/forgeloop 1.10.1 → 1.11.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/.cursor/rules/project-loop.mdc +2 -2
- package/.forgeloop/forgeloop.gitignore +1 -0
- package/.github/copilot-instructions.md +2 -2
- package/AGENTS.md +1 -0
- package/AGENT_COMPATIBILITY.md +7 -0
- package/CLAUDE.md +1 -0
- package/DOCS_INDEX.md +13 -1
- package/LOOP_SYSTEM_DESIGN.md +12 -0
- package/ORCHESTRATOR_INTEGRATION.md +9 -0
- package/PROTOCOL_INTEGRATION.md +17 -0
- package/README.md +21 -18
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/THREAT_MODEL.md +21 -0
- package/benchmarks/repository-index/README.md +73 -0
- package/benchmarks/repository-index/queries.json +12 -0
- package/benchmarks/repository-index/run-hot-path.mjs +142 -0
- package/benchmarks/repository-index/run-persistent-transport.mjs +169 -0
- package/completions/_forgeloop +7 -1
- package/completions/forgeloop.bash +13 -1
- package/completions/forgeloop.fish +40 -1
- package/docs/ADVISORY_CONTEXT.md +24 -0
- package/docs/AGENT_PROTOCOL_SUMMARY.md +8 -1
- package/docs/CLI_REFERENCE.md +114 -2
- package/docs/DOCUMENTATION_GUIDE.md +10 -5
- package/docs/GETTING_STARTED.md +17 -0
- package/docs/MCP.md +16 -1
- package/docs/PACKAGE_CONTENTS.md +12 -1
- package/docs/PERSISTENT_SEARCH_TRANSPORT.md +289 -0
- package/docs/RECIPES.md +30 -0
- package/docs/RELEASE_CHECKLIST.md +36 -2
- package/docs/REPOSITORY_INDEX.md +553 -0
- package/docs/RIPWIRE_ADAPTER.md +189 -0
- package/docs/TROUBLESHOOTING.md +147 -0
- package/docs/UNIVERSAL_INTEGRATION.md +30 -0
- package/docs/diagrams/README.md +19 -0
- package/package.json +12 -2
- package/scripts/update-tgrep-manifest.mjs +86 -0
- package/scripts/verify-tgrep-manifest.mjs +17 -0
- package/src/adapters/ripwire/normalize.js +352 -0
- package/src/adapters/ripwire/process.js +248 -0
- package/src/adapters/ripwire/provider.js +245 -0
- package/src/cli.js +35 -0
- package/src/commands/doctor.js +76 -1
- package/src/commands/index-rebuild.js +1 -0
- package/src/commands/index-setup.js +1 -0
- package/src/commands/index-start.js +1 -0
- package/src/commands/index-status.js +1 -0
- package/src/commands/index-stop.js +1 -0
- package/src/commands/init.js +39 -1
- package/src/commands/repository-index.js +111 -0
- package/src/commands/search.js +1 -0
- package/src/commands/update.js +32 -4
- package/src/core/advisory-context/service.js +36 -14
- package/src/core/cli-command-definitions.js +97 -3
- package/src/core/command-executors.js +35 -2
- package/src/core/command-input.js +23 -0
- package/src/core/error-codes.js +195 -0
- package/src/core/filesystem.js +10 -1
- package/src/core/handoff-acceptance.js +10 -1
- package/src/core/integration-invocation-policy.js +27 -0
- package/src/core/integration-resources.js +16 -1
- package/src/core/protocol-info.js +23 -0
- package/src/core/work-state.js +15 -6
- package/src/integration.d.ts +101 -0
- package/src/integration.js +22 -0
- package/src/persistent-transport/client.js +293 -0
- package/src/persistent-transport/constants.js +24 -0
- package/src/persistent-transport/errors.js +38 -0
- package/src/persistent-transport/framing.js +61 -0
- package/src/persistent-transport/lifecycle.js +116 -0
- package/src/persistent-transport/ownership.js +184 -0
- package/src/persistent-transport/paths.js +31 -0
- package/src/persistent-transport/protocol.js +95 -0
- package/src/persistent-transport/server.js +256 -0
- package/src/persistent-transport/state.js +49 -0
- package/src/repository-index/args.js +59 -0
- package/src/repository-index/binary-manager.js +413 -0
- package/src/repository-index/constants.js +45 -0
- package/src/repository-index/errors.js +38 -0
- package/src/repository-index/lifecycle.js +17 -0
- package/src/repository-index/lock.js +113 -0
- package/src/repository-index/manifest.js +132 -0
- package/src/repository-index/metrics.js +30 -0
- package/src/repository-index/normalize-json.js +187 -0
- package/src/repository-index/paths.js +39 -0
- package/src/repository-index/platform.js +20 -0
- package/src/repository-index/process.js +140 -0
- package/src/repository-index/readiness.js +62 -0
- package/src/repository-index/search.js +262 -0
- package/src/repository-index/server.js +432 -0
- package/src/repository-index/status.js +397 -0
- package/src/repository-index/tgrep-manifest.json +38 -0
|
@@ -20,6 +20,7 @@ import { readVerificationScope } from "./verification-scope.js";
|
|
|
20
20
|
import { resolveAttestationStatus } from "./attestation.js";
|
|
21
21
|
import { buildExecutionProfileContext } from "./execution-profile-context.js";
|
|
22
22
|
import { projectStructuralQualityStatus } from "./structural-quality/service.js";
|
|
23
|
+
import { getRepositoryIndexStatus } from "../repository-index/status.js";
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Canonical integration resource allowlist.
|
|
@@ -87,6 +88,7 @@ export const INTEGRATION_RESOURCE_DEFINITIONS = Object.freeze({
|
|
|
87
88
|
"task/context": Object.freeze({ scope: "TASK", description: "Read-only profile-aware task context with bounded presentation policy." }),
|
|
88
89
|
"task/evaluations": Object.freeze({ scope: "TASK", description: "Persisted trajectory evaluations for one task." }),
|
|
89
90
|
"project/capability-policy": Object.freeze({ scope: "PROJECT", description: "Project capability policy, never host authority." }),
|
|
91
|
+
"repository/index-status": Object.freeze({ scope: "PROJECT", description: "Provider-neutral repository-index health and owned-server status." }),
|
|
90
92
|
});
|
|
91
93
|
|
|
92
94
|
function ownershipProjection(projection) {
|
|
@@ -103,7 +105,7 @@ function ownershipProjection(projection) {
|
|
|
103
105
|
};
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
|
|
108
|
+
async function readForgeLoopIntegrationResourceCore(uri, {
|
|
107
109
|
projectPath = ".",
|
|
108
110
|
packageRoot = undefined,
|
|
109
111
|
packageVersion = null,
|
|
@@ -288,3 +290,16 @@ export async function readForgeLoopIntegrationResource(uri, {
|
|
|
288
290
|
const continuity = await runContinuity({ target: projectPath, packageRoot, taskId });
|
|
289
291
|
return { uri, taskId, data: continuity };
|
|
290
292
|
}
|
|
293
|
+
|
|
294
|
+
export async function readForgeLoopIntegrationResource(uri, options = {}) {
|
|
295
|
+
if (uri === "repository/index-status") {
|
|
296
|
+
return {
|
|
297
|
+
uri,
|
|
298
|
+
data: await getRepositoryIndexStatus(options.projectPath ?? ".", {
|
|
299
|
+
packageRoot: options.packageRoot,
|
|
300
|
+
...(options.repositoryIndexOptions ?? {}),
|
|
301
|
+
}),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return readForgeLoopIntegrationResourceCore(uri, options);
|
|
305
|
+
}
|
|
@@ -47,6 +47,29 @@ export function protocolInfo({ packageVersion = null } = {}) {
|
|
|
47
47
|
structuredCommandRuntime: true,
|
|
48
48
|
canonicalResources: true,
|
|
49
49
|
},
|
|
50
|
+
repositoryIndex: {
|
|
51
|
+
version: 1,
|
|
52
|
+
required: true,
|
|
53
|
+
providerNeutral: true,
|
|
54
|
+
implementation: "microsoft/tgrep",
|
|
55
|
+
engineVersion: "1.0.3",
|
|
56
|
+
engineManagedByForgeLoop: true,
|
|
57
|
+
commands: ["search", "index-setup", "index-start", "index-stop", "index-status", "index-rebuild"],
|
|
58
|
+
resource: "repository/index-status",
|
|
59
|
+
managedBinary: true,
|
|
60
|
+
path: ".forgeloop/repository-index/tgrep",
|
|
61
|
+
lifecycleStatePath: ".forgeloop/repository-index/engine-state.json",
|
|
62
|
+
noPathFallback: true,
|
|
63
|
+
supports: {
|
|
64
|
+
regex: true,
|
|
65
|
+
fixedStrings: true,
|
|
66
|
+
glob: true,
|
|
67
|
+
fileType: true,
|
|
68
|
+
context: true,
|
|
69
|
+
structuredOutput: true,
|
|
70
|
+
liveIndex: true,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
50
73
|
executionHistory: {
|
|
51
74
|
version: 1,
|
|
52
75
|
supported: true,
|
package/src/core/work-state.js
CHANGED
|
@@ -307,12 +307,21 @@ export async function mutateWorkState(target, { expectedRevision, packageRoot =
|
|
|
307
307
|
throw error;
|
|
308
308
|
}
|
|
309
309
|
if (!(await getTaskTransaction(target))) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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) {
|
package/src/integration.d.ts
CHANGED
|
@@ -19,6 +19,88 @@ export interface ForgeLoopCommandInput {
|
|
|
19
19
|
commandArgv?: string[];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export interface RepositorySearchRequest {
|
|
23
|
+
pattern: string;
|
|
24
|
+
globs?: string[];
|
|
25
|
+
types?: string[];
|
|
26
|
+
fixedStrings?: boolean;
|
|
27
|
+
ignoreCase?: boolean;
|
|
28
|
+
smartCase?: boolean;
|
|
29
|
+
wordRegexp?: boolean;
|
|
30
|
+
context?: number | null;
|
|
31
|
+
beforeContext?: number | null;
|
|
32
|
+
afterContext?: number | null;
|
|
33
|
+
maxCount?: number | null;
|
|
34
|
+
filesWithMatches?: boolean;
|
|
35
|
+
stats?: boolean;
|
|
36
|
+
/** Explicit host-selected binary override; ForgeLoop never discovers PATH executables. */
|
|
37
|
+
binaryPath?: string;
|
|
38
|
+
/** Preloaded archive used by the managed-engine provisioning path. */
|
|
39
|
+
assetPath?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RepositorySearchSubmatch {
|
|
43
|
+
start: number;
|
|
44
|
+
end: number;
|
|
45
|
+
match: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RepositorySearchMatch {
|
|
49
|
+
path: string;
|
|
50
|
+
line: number;
|
|
51
|
+
column: number | null;
|
|
52
|
+
offset: number | null;
|
|
53
|
+
text: string;
|
|
54
|
+
submatches: readonly RepositorySearchSubmatch[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RepositorySearchResult {
|
|
58
|
+
schemaVersion: 1;
|
|
59
|
+
query: RepositorySearchRequest & { globs: readonly string[]; types: readonly string[] };
|
|
60
|
+
repositoryIndex: { engine: string; engineVersion: string | null; indexed: boolean; server: boolean };
|
|
61
|
+
matches: readonly RepositorySearchMatch[];
|
|
62
|
+
contexts: readonly RepositorySearchMatch[];
|
|
63
|
+
files: readonly string[];
|
|
64
|
+
stats: Readonly<Record<string, number>>;
|
|
65
|
+
metrics: {
|
|
66
|
+
queryDurationMs: number;
|
|
67
|
+
nativeDurationMs: number;
|
|
68
|
+
matchCount: number;
|
|
69
|
+
matchedFileCount: number;
|
|
70
|
+
engine: string;
|
|
71
|
+
engineVersion: string | null;
|
|
72
|
+
serverUsed: boolean;
|
|
73
|
+
exitCode: number;
|
|
74
|
+
ignoredNativeEvents: number;
|
|
75
|
+
bytesSearched?: number;
|
|
76
|
+
matchedLines?: number;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RepositoryIndexStatus {
|
|
81
|
+
schemaVersion: 1;
|
|
82
|
+
required: true;
|
|
83
|
+
engine: "tgrep";
|
|
84
|
+
engineVersion: string | null;
|
|
85
|
+
managedBinary: boolean;
|
|
86
|
+
overridden: boolean;
|
|
87
|
+
index: {
|
|
88
|
+
present: boolean;
|
|
89
|
+
complete: boolean;
|
|
90
|
+
files: number | null;
|
|
91
|
+
trigrams: number | null;
|
|
92
|
+
createdAt: number | null;
|
|
93
|
+
updatedAt: number | null;
|
|
94
|
+
};
|
|
95
|
+
policy: { maxFileSize: string | number; maxCpuPercent: number; watcherQueueCap: number; autoSaveMutations: number };
|
|
96
|
+
server: { running: boolean; owned: boolean; pid: number | null; port: number | null; watcher: string; indexing: string; files: number | null };
|
|
97
|
+
health: "READY" | "INDEXING" | "NOT_INITIALIZED" | "ENGINE_MISSING" | "ENGINE_INVALID" | "SERVER_DOWN" | "SERVER_UNHEALTHY" | "ERROR";
|
|
98
|
+
diagnostics: readonly { code: string; message: string }[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export declare function repositorySearch(input: RepositorySearchRequest & { projectPath?: string }): Promise<RepositorySearchResult>;
|
|
102
|
+
export declare function repositoryIndexStatus(input?: { projectPath?: string; binaryPath?: string; assetPath?: string }): Promise<RepositoryIndexStatus>;
|
|
103
|
+
|
|
22
104
|
export interface ForgeLoopCommandEnvelope<T = unknown> {
|
|
23
105
|
ok: boolean;
|
|
24
106
|
command: string | null;
|
|
@@ -108,6 +190,14 @@ export interface ForgeLoopAdvisoryContextProvider {
|
|
|
108
190
|
|
|
109
191
|
export type ForgeLoopAdvisoryContextProviderFactory = () => ForgeLoopAdvisoryContextProvider | Promise<ForgeLoopAdvisoryContextProvider>;
|
|
110
192
|
|
|
193
|
+
/** Options for the optional, host-injected Ripwire advisory adapter. */
|
|
194
|
+
export interface ForgeLoopRipwireProviderOptions {
|
|
195
|
+
/** Absolute path selected by the host; ForgeLoop does not discover it. */
|
|
196
|
+
executablePath: string;
|
|
197
|
+
/** Exact version string qualified by the host and checked lazily at recall time. */
|
|
198
|
+
expectedVersion: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
111
201
|
export interface ForgeLoopNormalizedAdvisoryContextResult {
|
|
112
202
|
provider: {
|
|
113
203
|
id: string;
|
|
@@ -257,6 +347,14 @@ export declare function getForgeLoopCapabilities(input?: { packageVersion?: stri
|
|
|
257
347
|
export declare function classifyForgeLoopInvocation(command: string, input?: ForgeLoopCommandInput): ForgeLoopInvocationClassification;
|
|
258
348
|
export declare function readForgeLoopIntegrationResource(uri: string, input?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
259
349
|
export declare function resolveForgeLoopProjectRoot(projectPath?: string, input?: { cwd?: string }): Promise<string>;
|
|
350
|
+
export declare function searchRepository(request: RepositorySearchRequest & { repoRoot: string }): Promise<RepositorySearchResult>;
|
|
351
|
+
export declare function searchRepository(projectPath: string, request: RepositorySearchRequest): Promise<RepositorySearchResult>;
|
|
352
|
+
export declare function getRepositoryIndexStatus(projectPath: string, options?: Record<string, unknown>): Promise<RepositoryIndexStatus>;
|
|
353
|
+
export declare function setupRepositoryIndex(projectPath: string, options?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
354
|
+
export declare function startRepositoryIndexServer(projectPath: string, options?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
355
|
+
export declare function stopRepositoryIndexServer(projectPath: string, options?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
356
|
+
export declare function rebuildRepositoryIndex(projectPath: string, options?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
357
|
+
export declare function restartRepositoryIndexServer(projectPath: string, options?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
260
358
|
export declare function createForgeLoopContext(input?: Record<string, unknown>): ForgeLoopContext;
|
|
261
359
|
export declare function recallAdvisoryContext(input: {
|
|
262
360
|
target: string;
|
|
@@ -269,6 +367,9 @@ export declare function recallAdvisoryContext(input: {
|
|
|
269
367
|
timeoutMs?: number;
|
|
270
368
|
runtimeContext?: ForgeLoopContext | Record<string, unknown>;
|
|
271
369
|
}): Promise<ForgeLoopNormalizedAdvisoryContextResult>;
|
|
370
|
+
export declare function createRipwireAdvisoryContextProvider(
|
|
371
|
+
options: ForgeLoopRipwireProviderOptions,
|
|
372
|
+
): ForgeLoopAdvisoryContextProvider;
|
|
272
373
|
export declare const ADVISORY_CONTEXT_LIMITS: Readonly<Record<string, number>>;
|
|
273
374
|
export declare const ADVISORY_CONTEXT_TRUST: Readonly<Record<string, unknown>>;
|
|
274
375
|
export declare function normalizeAdvisoryRecallOptions(input?: Partial<ForgeLoopAdvisoryRecallOptions>): ForgeLoopAdvisoryRecallOptions;
|
package/src/integration.js
CHANGED
|
@@ -46,6 +46,27 @@ export {
|
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
export { createForgeLoopContext } from "./core/runtime-context.js";
|
|
49
|
+
export { searchRepository } from "./repository-index/search.js";
|
|
50
|
+
export { getRepositoryIndexStatus } from "./repository-index/status.js";
|
|
51
|
+
export {
|
|
52
|
+
rebuildRepositoryIndex,
|
|
53
|
+
restartRepositoryIndexServer,
|
|
54
|
+
setupRepositoryIndex,
|
|
55
|
+
startRepositoryIndexServer,
|
|
56
|
+
stopRepositoryIndexServer,
|
|
57
|
+
} from "./repository-index/server.js";
|
|
58
|
+
|
|
59
|
+
/** Stable Integration API operation for provider-neutral repository search. */
|
|
60
|
+
export async function repositorySearch({ projectPath = ".", ...request } = {}) {
|
|
61
|
+
const { searchRepository } = await import("./repository-index/search.js");
|
|
62
|
+
return searchRepository(projectPath, request);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Stable read-only Integration API projection for repository-index health. */
|
|
66
|
+
export async function repositoryIndexStatus({ projectPath = ".", ...options } = {}) {
|
|
67
|
+
const { getRepositoryIndexStatus } = await import("./repository-index/status.js");
|
|
68
|
+
return getRepositoryIndexStatus(projectPath, options);
|
|
69
|
+
}
|
|
49
70
|
export {
|
|
50
71
|
assertStructuralQualityProvider,
|
|
51
72
|
createStructuralQualityProviderRegistry,
|
|
@@ -81,6 +102,7 @@ export {
|
|
|
81
102
|
VERIFICATION_ISOLATION_MODES,
|
|
82
103
|
} from "./core/verification-execution.js";
|
|
83
104
|
export { recallAdvisoryContext } from "./core/advisory-context/service.js";
|
|
105
|
+
export { createRipwireAdvisoryContextProvider } from "./adapters/ripwire/provider.js";
|
|
84
106
|
export {
|
|
85
107
|
ADVISORY_CONTEXT_LIMITS,
|
|
86
108
|
ADVISORY_CONTEXT_TRUST,
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
import { getPackageRoot } from "../core/templates.js";
|
|
6
|
+
import { acquirePersistentTransportStartupLock, cleanPersistentTransportState, getPersistentTransportStatus as getLifecycleStatus, inspectPersistentTransport, startPersistentSearchHost } from "./lifecycle.js";
|
|
7
|
+
import { PERSISTENT_TRANSPORT_DEFAULTS, PERSISTENT_TRANSPORT_PROTOCOL_VERSION } from "./constants.js";
|
|
8
|
+
import { encodeFrame, FrameDecoder, parseFrame } from "./framing.js";
|
|
9
|
+
import { assertSearchParams, createRequest, projectSearchQuery, validateResponse } from "./protocol.js";
|
|
10
|
+
import { PERSISTENT_TRANSPORT_ERROR_CODES, isPersistentTransportError, persistentTransportError } from "./errors.js";
|
|
11
|
+
import { getPersistentTransportPaths } from "./paths.js";
|
|
12
|
+
import { readPersistentTransportState } from "./state.js";
|
|
13
|
+
|
|
14
|
+
function delay(ms) {
|
|
15
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let forgeLoopVersionPromise;
|
|
19
|
+
|
|
20
|
+
async function currentForgeLoopVersion() {
|
|
21
|
+
forgeLoopVersionPromise ??= readFile(path.join(getPackageRoot(), "package.json"), "utf8")
|
|
22
|
+
.then((raw) => JSON.parse(raw).version ?? null);
|
|
23
|
+
return forgeLoopVersionPromise;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isConnectionFailure(error) {
|
|
27
|
+
return isPersistentTransportError(error)
|
|
28
|
+
&& [PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE, PERSISTENT_TRANSPORT_ERROR_CODES.TIMEOUT, PERSISTENT_TRANSPORT_ERROR_CODES.PROTOCOL_MISMATCH, PERSISTENT_TRANSPORT_ERROR_CODES.INVALID_RESPONSE].includes(error.code);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function remoteError(response) {
|
|
32
|
+
const error = persistentTransportError(response.error.code, response.error.message, {
|
|
33
|
+
expectedVersion: response.error.expectedVersion,
|
|
34
|
+
actualVersion: response.error.actualVersion,
|
|
35
|
+
});
|
|
36
|
+
error.remote = true;
|
|
37
|
+
return error;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function connect(endpoint, timeoutMs) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
let settled = false;
|
|
43
|
+
const socket = net.createConnection(endpoint);
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
if (settled) return;
|
|
46
|
+
settled = true;
|
|
47
|
+
socket.destroy();
|
|
48
|
+
reject(persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.TIMEOUT, "Timed out connecting to the persistent search host"));
|
|
49
|
+
}, timeoutMs);
|
|
50
|
+
const finish = (fn, value) => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
fn(value);
|
|
55
|
+
};
|
|
56
|
+
socket.once("connect", () => {
|
|
57
|
+
socket.setNoDelay?.(true);
|
|
58
|
+
finish(resolve, socket);
|
|
59
|
+
});
|
|
60
|
+
socket.once("error", (cause) => finish(reject, persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE, `Persistent search host is unavailable: ${cause.code ?? cause.message}`)));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function exchange(socket, request, timeoutMs, {
|
|
65
|
+
requestMaxFrameBytes = PERSISTENT_TRANSPORT_DEFAULTS.maxRequestFrameBytes,
|
|
66
|
+
responseMaxFrameBytes = PERSISTENT_TRANSPORT_DEFAULTS.maxResponseFrameBytes,
|
|
67
|
+
} = {}) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const decoder = new FrameDecoder({ maxFrameBytes: responseMaxFrameBytes });
|
|
70
|
+
let settled = false;
|
|
71
|
+
const timer = setTimeout(() => finish(reject, persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.TIMEOUT, "Timed out waiting for the persistent search host")), timeoutMs);
|
|
72
|
+
const finish = (fn, value) => {
|
|
73
|
+
if (settled) return;
|
|
74
|
+
settled = true;
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
socket.off("data", onData);
|
|
77
|
+
socket.off("error", onError);
|
|
78
|
+
socket.off("end", onEnd);
|
|
79
|
+
fn(value);
|
|
80
|
+
};
|
|
81
|
+
const onData = (chunk) => {
|
|
82
|
+
try {
|
|
83
|
+
for (const frame of decoder.push(chunk)) {
|
|
84
|
+
const response = validateResponse(parseFrame(frame), request.id);
|
|
85
|
+
finish(resolve, response);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
finish(reject, error.code ? error : persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.INVALID_RESPONSE, error.message));
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const onError = (cause) => finish(reject, persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE, `Persistent search host connection failed: ${cause.code ?? cause.message}`));
|
|
93
|
+
const onEnd = () => {
|
|
94
|
+
try { decoder.end(); } catch (error) { finish(reject, error); return; }
|
|
95
|
+
finish(reject, persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE, "Persistent search host closed the connection"));
|
|
96
|
+
};
|
|
97
|
+
socket.on("data", onData);
|
|
98
|
+
socket.once("error", onError);
|
|
99
|
+
socket.once("end", onEnd);
|
|
100
|
+
try {
|
|
101
|
+
socket.write(encodeFrame(request, { maxFrameBytes: requestMaxFrameBytes }));
|
|
102
|
+
} catch (error) {
|
|
103
|
+
finish(reject, error);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function openHost({
|
|
109
|
+
paths,
|
|
110
|
+
timeoutMs,
|
|
111
|
+
state,
|
|
112
|
+
requestMaxFrameBytes = PERSISTENT_TRANSPORT_DEFAULTS.maxRequestFrameBytes,
|
|
113
|
+
responseMaxFrameBytes = PERSISTENT_TRANSPORT_DEFAULTS.maxResponseFrameBytes,
|
|
114
|
+
}) {
|
|
115
|
+
const socket = await connect(paths.endpoint, timeoutMs);
|
|
116
|
+
try {
|
|
117
|
+
const handshakeRequest = createRequest("handshake", { scopeId: paths.scopeId });
|
|
118
|
+
const handshake = await exchange(socket, handshakeRequest, timeoutMs, { requestMaxFrameBytes, responseMaxFrameBytes });
|
|
119
|
+
if (!handshake.ok) throw remoteError(handshake);
|
|
120
|
+
const expectedForgeLoopVersion = await currentForgeLoopVersion();
|
|
121
|
+
if (!handshake.result
|
|
122
|
+
|| handshake.result.protocolVersion !== PERSISTENT_TRANSPORT_PROTOCOL_VERSION
|
|
123
|
+
|| handshake.result.scopeId !== paths.scopeId
|
|
124
|
+
|| handshake.result.forgeLoopVersion !== expectedForgeLoopVersion
|
|
125
|
+
|| (state?.forgeLoopVersion && handshake.result.forgeLoopVersion !== state.forgeLoopVersion)) {
|
|
126
|
+
throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.PROTOCOL_MISMATCH, "Persistent search host handshake is incompatible", { expectedVersion: PERSISTENT_TRANSPORT_PROTOCOL_VERSION, actualVersion: handshake.result?.protocolVersion });
|
|
127
|
+
}
|
|
128
|
+
if (state?.nonce && handshake.result.nonce !== state.nonce) {
|
|
129
|
+
throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.OWNERSHIP_UNVERIFIED, "Persistent search host handshake ownership does not match its state");
|
|
130
|
+
}
|
|
131
|
+
return { socket, handshake: handshake.result };
|
|
132
|
+
} catch (error) {
|
|
133
|
+
socket.destroy();
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function pingHost({ homeDirectory, timeoutMs, maxFrameBytes }) {
|
|
139
|
+
const paths = getPersistentTransportPaths({ homeDirectory });
|
|
140
|
+
const { state } = await readPersistentTransportState(paths.statePath);
|
|
141
|
+
if (!state) throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE, "Persistent search host has not published its state");
|
|
142
|
+
const connection = await openHost({ paths, timeoutMs, state, responseMaxFrameBytes: maxFrameBytes });
|
|
143
|
+
try {
|
|
144
|
+
return connection.handshake;
|
|
145
|
+
} finally {
|
|
146
|
+
connection.socket.end();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function pingPersistentSearchHost({ homeDirectory = undefined, timeoutMs = 2_000 } = {}) {
|
|
151
|
+
const resolvedHome = homeDirectory ?? process.env.FORGELOOP_PERSISTENT_TRANSPORT_HOME ?? undefined;
|
|
152
|
+
return pingHost({
|
|
153
|
+
homeDirectory: resolvedHome,
|
|
154
|
+
timeoutMs,
|
|
155
|
+
maxFrameBytes: PERSISTENT_TRANSPORT_DEFAULTS.maxResponseFrameBytes,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function searchQuery(request) {
|
|
160
|
+
return projectSearchQuery(request);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function requestSearch({ repositoryRoot, request, options, timeoutMs, maxFrameBytes }) {
|
|
164
|
+
const homeDirectory = options.homeDirectory;
|
|
165
|
+
const paths = getPersistentTransportPaths({ homeDirectory });
|
|
166
|
+
const { state } = await readPersistentTransportState(paths.statePath);
|
|
167
|
+
const params = assertSearchParams({ repository: repositoryRoot, query: searchQuery(request) });
|
|
168
|
+
const connection = await openHost({ paths, timeoutMs, state, responseMaxFrameBytes: maxFrameBytes });
|
|
169
|
+
try {
|
|
170
|
+
const response = await exchange(connection.socket, createRequest("repository.search", params), timeoutMs, {
|
|
171
|
+
responseMaxFrameBytes: maxFrameBytes,
|
|
172
|
+
});
|
|
173
|
+
if (!response.ok) throw remoteError(response);
|
|
174
|
+
return response.result;
|
|
175
|
+
} finally {
|
|
176
|
+
connection.socket.end();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function ensureHost({ homeDirectory, idleTimeoutMs, startupTimeoutMs, env, recover = false }) {
|
|
181
|
+
const deadline = Date.now() + startupTimeoutMs;
|
|
182
|
+
let lock = null;
|
|
183
|
+
while (!lock) {
|
|
184
|
+
lock = await acquirePersistentTransportStartupLock({ homeDirectory, tryOnly: true });
|
|
185
|
+
if (lock) break;
|
|
186
|
+
if (Date.now() >= deadline) {
|
|
187
|
+
throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.START_FAILED, "Persistent search host startup coordination exceeded its bounded timeout");
|
|
188
|
+
}
|
|
189
|
+
await delay(Math.min(25, Math.max(1, deadline - Date.now())));
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
let inspection = await inspectPersistentTransport({ homeDirectory });
|
|
193
|
+
if (inspection.status === "OWNERSHIP_UNVERIFIED") {
|
|
194
|
+
throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.OWNERSHIP_UNVERIFIED, "A process owns the persistent search endpoint but is not a verified ForgeLoop host");
|
|
195
|
+
}
|
|
196
|
+
if (inspection.status === "STALE") {
|
|
197
|
+
await cleanPersistentTransportState(inspection);
|
|
198
|
+
inspection = await inspectPersistentTransport({ homeDirectory });
|
|
199
|
+
}
|
|
200
|
+
if (inspection.status === "INCOMPATIBLE" || (recover && inspection.status === "READY")) {
|
|
201
|
+
await cleanPersistentTransportState(inspection);
|
|
202
|
+
inspection = await inspectPersistentTransport({ homeDirectory });
|
|
203
|
+
}
|
|
204
|
+
if (inspection.status === "READY") {
|
|
205
|
+
try {
|
|
206
|
+
await pingHost({ homeDirectory, timeoutMs: Math.min(startupTimeoutMs, 2_000), maxFrameBytes: PERSISTENT_TRANSPORT_DEFAULTS.maxResponseFrameBytes });
|
|
207
|
+
return inspection;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (!recover && !isConnectionFailure(error)) throw error;
|
|
210
|
+
await cleanPersistentTransportState(inspection);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
await startPersistentSearchHost({ homeDirectory, idleTimeoutMs, env });
|
|
214
|
+
const deadline = Date.now() + startupTimeoutMs;
|
|
215
|
+
while (Date.now() < deadline) {
|
|
216
|
+
try {
|
|
217
|
+
await pingHost({ homeDirectory, timeoutMs: Math.min(1_000, Math.max(100, deadline - Date.now())), maxFrameBytes: PERSISTENT_TRANSPORT_DEFAULTS.maxResponseFrameBytes });
|
|
218
|
+
return await inspectPersistentTransport({ homeDirectory });
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (!isConnectionFailure(error) && error.code !== PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE) throw error;
|
|
221
|
+
await delay(25);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.START_FAILED, "Persistent search host did not become ready within the startup timeout");
|
|
225
|
+
} finally {
|
|
226
|
+
await lock.release();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const startupCoordinators = new Map();
|
|
231
|
+
|
|
232
|
+
function coordinateEnsureHost(options) {
|
|
233
|
+
const key = getPersistentTransportPaths({ homeDirectory: options.homeDirectory }).root;
|
|
234
|
+
const active = startupCoordinators.get(key);
|
|
235
|
+
if (active) return active;
|
|
236
|
+
const pending = ensureHost(options);
|
|
237
|
+
startupCoordinators.set(key, pending);
|
|
238
|
+
const clear = () => {
|
|
239
|
+
if (startupCoordinators.get(key) === pending) startupCoordinators.delete(key);
|
|
240
|
+
};
|
|
241
|
+
pending.then(clear, clear);
|
|
242
|
+
return pending;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function searchViaPersistentTransport(repositoryRoot, request = {}, options = {}) {
|
|
246
|
+
const canonicalRoot = await realpath(repositoryRoot);
|
|
247
|
+
const homeDirectory = options.homeDirectory ?? process.env.FORGELOOP_PERSISTENT_TRANSPORT_HOME ?? undefined;
|
|
248
|
+
const transportOptions = {
|
|
249
|
+
homeDirectory,
|
|
250
|
+
idleTimeoutMs: options.idleTimeoutMs ?? PERSISTENT_TRANSPORT_DEFAULTS.idleTimeoutMs,
|
|
251
|
+
startupTimeoutMs: options.startupTimeoutMs ?? PERSISTENT_TRANSPORT_DEFAULTS.startupTimeoutMs,
|
|
252
|
+
requestTimeoutMs: options.requestTimeoutMs ?? PERSISTENT_TRANSPORT_DEFAULTS.requestTimeoutMs,
|
|
253
|
+
maxFrameBytes: options.maxResponseFrameBytes ?? PERSISTENT_TRANSPORT_DEFAULTS.maxResponseFrameBytes,
|
|
254
|
+
env: options.env ?? {},
|
|
255
|
+
};
|
|
256
|
+
let lastError;
|
|
257
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
258
|
+
try {
|
|
259
|
+
return await requestSearch({ repositoryRoot: canonicalRoot, request, options: transportOptions, timeoutMs: transportOptions.requestTimeoutMs, maxFrameBytes: transportOptions.maxFrameBytes });
|
|
260
|
+
} catch (error) {
|
|
261
|
+
lastError = error;
|
|
262
|
+
if (error.remote || !isConnectionFailure(error)) throw error;
|
|
263
|
+
await coordinateEnsureHost({ ...transportOptions, recover: attempt === 1 });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
throw lastError ?? persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE, "Persistent search host is unavailable");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function shutdownPersistentSearchHost({ homeDirectory = undefined, timeoutMs = 2_000 } = {}) {
|
|
270
|
+
const resolvedHome = homeDirectory ?? process.env.FORGELOOP_PERSISTENT_TRANSPORT_HOME ?? undefined;
|
|
271
|
+
const inspection = await inspectPersistentTransport({ homeDirectory: resolvedHome });
|
|
272
|
+
if (inspection.status === "NOT_RUNNING") return { status: "NOT_RUNNING" };
|
|
273
|
+
if (!inspection.owned) throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.OWNERSHIP_UNVERIFIED, "Persistent search host ownership could not be verified");
|
|
274
|
+
const paths = getPersistentTransportPaths({ homeDirectory: resolvedHome });
|
|
275
|
+
const connection = await openHost({ paths, timeoutMs, state: inspection.state });
|
|
276
|
+
try {
|
|
277
|
+
const response = await exchange(connection.socket, createRequest("transport.shutdown", { nonce: inspection.state.nonce }), timeoutMs);
|
|
278
|
+
if (!response.ok) throw remoteError(response);
|
|
279
|
+
const deadline = Date.now() + timeoutMs;
|
|
280
|
+
while (Date.now() < deadline) {
|
|
281
|
+
const status = await getLifecycleStatus({ homeDirectory: resolvedHome });
|
|
282
|
+
if (status.status === "NOT_RUNNING" || status.status === "STALE") break;
|
|
283
|
+
await delay(25);
|
|
284
|
+
}
|
|
285
|
+
return response.result;
|
|
286
|
+
} finally {
|
|
287
|
+
connection.socket.end();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function getPersistentTransportStatus(options = {}) {
|
|
292
|
+
return getLifecycleStatus(options);
|
|
293
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const PERSISTENT_TRANSPORT_PROTOCOL_VERSION = 1;
|
|
5
|
+
export const PERSISTENT_TRANSPORT_SCHEMA_VERSION = 1;
|
|
6
|
+
export const PERSISTENT_TRANSPORT_DEFAULTS = Object.freeze({
|
|
7
|
+
startupTimeoutMs: 15_000,
|
|
8
|
+
requestTimeoutMs: 120_000,
|
|
9
|
+
idleTimeoutMs: 10 * 60 * 1_000,
|
|
10
|
+
maxRequestFrameBytes: 1 * 1024 * 1024,
|
|
11
|
+
maxResponseFrameBytes: 16 * 1024 * 1024,
|
|
12
|
+
maxPatternChars: 4_096,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const PERSISTENT_TRANSPORT_METHODS = Object.freeze([
|
|
16
|
+
"handshake",
|
|
17
|
+
"repository.search",
|
|
18
|
+
"transport.status",
|
|
19
|
+
"transport.shutdown",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function getPersistentTransportRoot({ homeDirectory = os.homedir() } = {}) {
|
|
23
|
+
return path.join(homeDirectory, ".forgeloop", "persistent-search");
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
E_PERSISTENT_TRANSPORT_FRAME_INVALID,
|
|
3
|
+
E_PERSISTENT_TRANSPORT_FRAME_TOO_LARGE,
|
|
4
|
+
E_PERSISTENT_TRANSPORT_HOST_STALE,
|
|
5
|
+
E_PERSISTENT_TRANSPORT_INVALID_REQUEST,
|
|
6
|
+
E_PERSISTENT_TRANSPORT_INVALID_RESPONSE,
|
|
7
|
+
E_PERSISTENT_TRANSPORT_OWNERSHIP_UNVERIFIED,
|
|
8
|
+
E_PERSISTENT_TRANSPORT_PROTOCOL_MISMATCH,
|
|
9
|
+
E_PERSISTENT_TRANSPORT_START_FAILED,
|
|
10
|
+
E_PERSISTENT_TRANSPORT_TIMEOUT,
|
|
11
|
+
E_PERSISTENT_TRANSPORT_UNAVAILABLE,
|
|
12
|
+
} from "../core/error-codes.js";
|
|
13
|
+
|
|
14
|
+
export const PERSISTENT_TRANSPORT_ERROR_CODES = Object.freeze({
|
|
15
|
+
FRAME_INVALID: E_PERSISTENT_TRANSPORT_FRAME_INVALID,
|
|
16
|
+
FRAME_TOO_LARGE: E_PERSISTENT_TRANSPORT_FRAME_TOO_LARGE,
|
|
17
|
+
HOST_STALE: E_PERSISTENT_TRANSPORT_HOST_STALE,
|
|
18
|
+
INVALID_REQUEST: E_PERSISTENT_TRANSPORT_INVALID_REQUEST,
|
|
19
|
+
INVALID_RESPONSE: E_PERSISTENT_TRANSPORT_INVALID_RESPONSE,
|
|
20
|
+
OWNERSHIP_UNVERIFIED: E_PERSISTENT_TRANSPORT_OWNERSHIP_UNVERIFIED,
|
|
21
|
+
PROTOCOL_MISMATCH: E_PERSISTENT_TRANSPORT_PROTOCOL_MISMATCH,
|
|
22
|
+
START_FAILED: E_PERSISTENT_TRANSPORT_START_FAILED,
|
|
23
|
+
TIMEOUT: E_PERSISTENT_TRANSPORT_TIMEOUT,
|
|
24
|
+
UNAVAILABLE: E_PERSISTENT_TRANSPORT_UNAVAILABLE,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export function persistentTransportError(code, message, details = {}) {
|
|
28
|
+
const error = new Error(message);
|
|
29
|
+
error.code = code;
|
|
30
|
+
for (const [key, value] of Object.entries(details)) {
|
|
31
|
+
if (value !== undefined) error[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isPersistentTransportError(error) {
|
|
37
|
+
return typeof error?.code === "string" && error.code.startsWith("E_PERSISTENT_TRANSPORT_");
|
|
38
|
+
}
|