@cassiomc1/forgeloop 1.10.2 → 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 +10 -0
- package/LOOP_SYSTEM_DESIGN.md +12 -0
- package/ORCHESTRATOR_INTEGRATION.md +9 -0
- package/PROTOCOL_INTEGRATION.md +17 -0
- package/README.md +12 -10
- 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/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 +7 -1
- package/docs/PERSISTENT_SEARCH_TRANSPORT.md +289 -0
- package/docs/RECIPES.md +30 -0
- package/docs/RELEASE_CHECKLIST.md +34 -0
- package/docs/REPOSITORY_INDEX.md +553 -0
- package/docs/TROUBLESHOOTING.md +147 -0
- package/docs/UNIVERSAL_INTEGRATION.md +30 -0
- package/docs/diagrams/README.md +10 -0
- package/package.json +11 -2
- package/scripts/update-tgrep-manifest.mjs +86 -0
- package/scripts/verify-tgrep-manifest.mjs +17 -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/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/integration-invocation-policy.js +27 -0
- package/src/core/integration-resources.js +16 -1
- package/src/core/protocol-info.js +23 -0
- package/src/integration.d.ts +90 -0
- package/src/integration.js +21 -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
package/src/core/filesystem.js
CHANGED
|
@@ -129,6 +129,7 @@ export async function assertSafePath(root, relativePath) {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
let existing = destination;
|
|
132
|
+
let relativeExisting = path.normalize(relativePath);
|
|
132
133
|
while (true) {
|
|
133
134
|
try {
|
|
134
135
|
const info = await lstatWithTransientWindowsRetry(existing);
|
|
@@ -136,7 +137,14 @@ export async function assertSafePath(root, relativePath) {
|
|
|
136
137
|
throw new Error(`Path uses a symlink inside target directory: ${relativePath}`);
|
|
137
138
|
}
|
|
138
139
|
const resolvedRoot = await realpathWithTransientWindowsRetry(absoluteRoot);
|
|
139
|
-
|
|
140
|
+
// Resolve the comparison path from the canonical root. Windows can
|
|
141
|
+
// return different equivalent spellings (for example, short names or
|
|
142
|
+
// extended-length prefixes) for the root and a child path when those
|
|
143
|
+
// paths are canonicalized independently. Reconstructing the child from
|
|
144
|
+
// the canonical root keeps the comparison in one namespace without
|
|
145
|
+
// weakening the realpath containment check for junctions or symlinks.
|
|
146
|
+
const canonicalExisting = path.resolve(resolvedRoot, relativeExisting);
|
|
147
|
+
const resolvedExisting = await realpathWithTransientWindowsRetry(canonicalExisting);
|
|
140
148
|
if (!isPathWithin(resolvedRoot, resolvedExisting)) {
|
|
141
149
|
throw new Error(`Path escapes target directory: ${relativePath}`);
|
|
142
150
|
}
|
|
@@ -146,6 +154,7 @@ export async function assertSafePath(root, relativePath) {
|
|
|
146
154
|
const parent = path.dirname(existing);
|
|
147
155
|
if (parent === existing) throw new Error(`Path does not resolve inside target directory: ${relativePath}`);
|
|
148
156
|
existing = parent;
|
|
157
|
+
relativeExisting = path.dirname(relativeExisting);
|
|
149
158
|
}
|
|
150
159
|
}
|
|
151
160
|
}
|
|
@@ -33,6 +33,7 @@ const READ_ONLY_COMMANDS = Object.freeze(new Set([
|
|
|
33
33
|
"workspace-status", "handoff-list", "handoff-show", "responsibility-status",
|
|
34
34
|
"attestation-verify", "attestation-status", "attestation-verify-range",
|
|
35
35
|
"quality-status",
|
|
36
|
+
"index-status", "search",
|
|
36
37
|
]));
|
|
37
38
|
|
|
38
39
|
const LOOP_MUTATION_COMMANDS = Object.freeze(new Set([
|
|
@@ -73,6 +74,10 @@ const STATIC_RISK_CLASSES = Object.freeze({
|
|
|
73
74
|
"migrate-protocol": INTEGRATION_RISK_CLASSES.MAINTENANCE,
|
|
74
75
|
"clear-state": INTEGRATION_RISK_CLASSES.MAINTENANCE,
|
|
75
76
|
doctor: INTEGRATION_RISK_CLASSES.MAINTENANCE,
|
|
77
|
+
"index-setup": INTEGRATION_RISK_CLASSES.MAINTENANCE,
|
|
78
|
+
"index-start": INTEGRATION_RISK_CLASSES.MAINTENANCE,
|
|
79
|
+
"index-stop": INTEGRATION_RISK_CLASSES.MAINTENANCE,
|
|
80
|
+
"index-rebuild": INTEGRATION_RISK_CLASSES.MAINTENANCE,
|
|
76
81
|
"policy-discover": INTEGRATION_RISK_CLASSES.MAINTENANCE,
|
|
77
82
|
baseline: INTEGRATION_RISK_CLASSES.MAINTENANCE,
|
|
78
83
|
"task-unlock": INTEGRATION_RISK_CLASSES.MAINTENANCE,
|
|
@@ -249,6 +254,27 @@ export function getForgeLoopCapabilities({ packageVersion = null } = {}) {
|
|
|
249
254
|
modes: [...VERIFICATION_ISOLATION_MODES],
|
|
250
255
|
protocolProjectRootSeparateFromExecutionCwd: true,
|
|
251
256
|
},
|
|
257
|
+
repositoryIndex: {
|
|
258
|
+
version: 1,
|
|
259
|
+
required: true,
|
|
260
|
+
providerNeutral: true,
|
|
261
|
+
implementation: "microsoft/tgrep",
|
|
262
|
+
engineVersion: "1.0.3",
|
|
263
|
+
engineManagedByForgeLoop: true,
|
|
264
|
+
commands: ["search", "index-setup", "index-start", "index-stop", "index-status", "index-rebuild"],
|
|
265
|
+
resource: "repository/index-status",
|
|
266
|
+
managedBinary: true,
|
|
267
|
+
noPathFallback: true,
|
|
268
|
+
supports: {
|
|
269
|
+
regex: true,
|
|
270
|
+
fixedStrings: true,
|
|
271
|
+
glob: true,
|
|
272
|
+
fileType: true,
|
|
273
|
+
context: true,
|
|
274
|
+
structuredOutput: true,
|
|
275
|
+
liveIndex: true,
|
|
276
|
+
},
|
|
277
|
+
},
|
|
252
278
|
},
|
|
253
279
|
commands,
|
|
254
280
|
resources: [
|
|
@@ -271,6 +297,7 @@ export function getForgeLoopCapabilities({ packageVersion = null } = {}) {
|
|
|
271
297
|
{ name: "task/context", scope: "TASK" },
|
|
272
298
|
{ name: "task/evaluations", scope: "TASK" },
|
|
273
299
|
{ name: "project/capability-policy", scope: "PROJECT" },
|
|
300
|
+
{ name: "repository/index-status", scope: "PROJECT" },
|
|
274
301
|
],
|
|
275
302
|
};
|
|
276
303
|
}
|
|
@@ -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/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;
|
|
@@ -265,6 +347,14 @@ export declare function getForgeLoopCapabilities(input?: { packageVersion?: stri
|
|
|
265
347
|
export declare function classifyForgeLoopInvocation(command: string, input?: ForgeLoopCommandInput): ForgeLoopInvocationClassification;
|
|
266
348
|
export declare function readForgeLoopIntegrationResource(uri: string, input?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
267
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>>;
|
|
268
358
|
export declare function createForgeLoopContext(input?: Record<string, unknown>): ForgeLoopContext;
|
|
269
359
|
export declare function recallAdvisoryContext(input: {
|
|
270
360
|
target: string;
|
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,
|
|
@@ -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
|
+
}
|