@inneranimalmedia/agentsam-sdk 2.6.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/docs/PLATFORM_RUNTIME_EVENTS.md +48 -0
  2. package/docs/RELEASES.md +7 -7
  3. package/docs/SOURCE_ARCHITECTURE.md +58 -0
  4. package/docs/TEST_TIERS.md +26 -0
  5. package/migrations/runtime/0001_cli_runtime.sql +298 -0
  6. package/package.json +28 -7
  7. package/packages/agentsam-repository/README.md +15 -0
  8. package/packages/agentsam-repository/package.json +25 -0
  9. package/packages/agentsam-repository/src/contracts.js +113 -0
  10. package/packages/agentsam-repository/src/index.js +3 -0
  11. package/{src/lib → packages/agentsam-repository/src}/merkle/cloudflare-persistence.js +14 -24
  12. package/{src/lib → packages/agentsam-repository/src}/merkle/index.js +1 -0
  13. package/{src/lib → packages/agentsam-repository/src}/merkle/persistence.js +6 -4
  14. package/{src/lib → packages/agentsam-repository/src}/merkle/policy.js +1 -0
  15. package/packages/agentsam-repository/test/contracts.test.mjs +40 -0
  16. package/packages/agentsam-repository/test/git-context.test.mjs +24 -0
  17. package/{test/merkle.test.mjs → packages/agentsam-repository/test/merkle-core.test.mjs} +2 -32
  18. package/{test → packages/agentsam-repository/test}/merkle-persistence.test.mjs +11 -6
  19. package/packages/identity/package.json +1 -1
  20. package/protocol/COMPANY_REPOSITORY_GRAPH_V1.md +91 -0
  21. package/protocol/MERKLE_PERSISTENCE_V1.md +2 -0
  22. package/protocol/MERKLE_PERSISTENCE_V2.md +40 -0
  23. package/protocol/repository/repository-contract.schema.json +24 -0
  24. package/protocol/repository/repository-dependency.schema.json +24 -0
  25. package/protocol/repository/repository-identity.schema.json +17 -0
  26. package/protocol/rpc/v1/common.proto +16 -0
  27. package/protocol/rpc/v1/errors.proto +35 -0
  28. package/protocol/rpc/v1/knowledge.proto +77 -0
  29. package/services/knowledge/package-lock.json +333 -0
  30. package/services/knowledge/package.json +5 -1
  31. package/src/agent/responses-runner.js +63 -35
  32. package/src/capabilities/repository-snapshot.js +3 -3
  33. package/src/cli.js +23 -6
  34. package/src/commands/context-economics.js +17 -2
  35. package/src/commands/context.js +1 -1
  36. package/src/commands/db.js +20 -3
  37. package/src/commands/env.js +90 -0
  38. package/src/commands/knowledge.js +12 -4
  39. package/src/commands/merkle-persist.js +30 -11
  40. package/src/commands/merkle.js +1 -1
  41. package/src/commands/models.js +123 -65
  42. package/src/commands/ollama.js +26 -0
  43. package/src/commands/preferences.js +53 -26
  44. package/src/commands/shell.js +236 -48
  45. package/src/errors/contract.js +236 -0
  46. package/src/errors/index.js +14 -0
  47. package/src/index.js +13 -1
  48. package/src/knowledge/service/auth.js +13 -0
  49. package/src/knowledge/service/grpc-client.js +115 -0
  50. package/src/knowledge/service/grpc-codec.js +237 -0
  51. package/src/knowledge/service/grpc-server.js +83 -0
  52. package/src/knowledge/service/job-engine.js +248 -0
  53. package/src/knowledge/service/server.js +87 -135
  54. package/src/knowledge/source.js +1 -1
  55. package/src/lib/cli-preferences.js +31 -4
  56. package/src/lib/deploy-receipt/index.js +2 -2
  57. package/src/lib/knowledge-docker.js +6 -3
  58. package/src/lib/local-sessions.js +23 -2
  59. package/src/lib/local-status.js +1 -1
  60. package/src/lib/project-config.js +1 -1
  61. package/src/lib/provider-credentials.js +105 -5
  62. package/src/lib/slash-commands.js +4 -3
  63. package/src/local/migrations.js +93 -0
  64. package/src/local/runtime-store.js +141 -0
  65. package/src/local/sqlite.js +2 -0
  66. package/src/local-pty/server.js +113 -51
  67. package/src/models/discovery.js +292 -0
  68. package/src/providers/anthropic-messages.js +192 -0
  69. package/src/providers/cloudflare-chat.js +183 -0
  70. package/src/providers/factory.js +69 -0
  71. package/src/providers/gemini-generate-content.js +208 -0
  72. package/src/providers/index.js +5 -0
  73. package/src/providers/ollama-chat.js +148 -0
  74. package/src/providers/openai-responses.js +226 -75
  75. package/src/repository/index.js +14 -2
  76. package/src/rpc/generated/common_grpc_pb.js +1 -0
  77. package/src/rpc/generated/common_pb.js +536 -0
  78. package/src/rpc/generated/errors_grpc_pb.js +1 -0
  79. package/src/rpc/generated/errors_pb.js +482 -0
  80. package/src/rpc/generated/knowledge_grpc_pb.js +135 -0
  81. package/src/rpc/generated/knowledge_pb.js +2168 -0
  82. package/src/rpc/generated/package.json +3 -0
  83. package/src/security/trust-boundary.js +2 -2
  84. package/src/telemetry/events.js +4 -1
  85. package/src/ui/cli/activity.js +76 -0
  86. package/src/ui/cli/compaction.js +15 -0
  87. package/src/ui/cli/footer.js +39 -0
  88. package/src/ui/cli/help.js +192 -0
  89. package/src/ui/cli/plan.js +20 -0
  90. package/src/ui/cli/runtime-events.js +110 -0
  91. package/src/ui/cli/waiting.js +16 -0
  92. package/src/ui/merkle/render.js +1 -1
  93. package/test/cli/preferences-runtime.test.mjs +11 -0
  94. package/test/cli/runtime-ui.test.mjs +74 -0
  95. package/test/error-diagnostics.test.mjs +57 -1
  96. package/test/fixtures/knowledge-rpc-worker.mjs +16 -0
  97. package/test/integration/cli-help.test.mjs +37 -0
  98. package/test/integration/knowledge-rpc.test.mjs +112 -0
  99. package/test/integration/merkle-cli.test.mjs +61 -0
  100. package/test/integration/merkle-persistence-identity.test.mjs +48 -0
  101. package/test/integration/provider-env-cli.test.mjs +49 -0
  102. package/test/integration/provider-factory.test.mjs +197 -0
  103. package/test/integration/repository-company-graph.test.mjs +90 -0
  104. package/test/integration/runtime-migrations.test.mjs +82 -0
  105. package/test/knowledge-service.test.mjs +5 -0
  106. package/test/knowledge.test.mjs +16 -0
  107. package/test/live/terminal-transport.live.test.mjs +24 -0
  108. package/test/local-sessions.test.mjs +7 -1
  109. package/test/models.test.mjs +101 -4
  110. package/test/ollama.test.mjs +21 -0
  111. package/test/portable-context.test.mjs +1 -1
  112. package/test/provider-credentials.test.mjs +45 -1
  113. package/test/release-hygiene.test.mjs +13 -5
  114. package/test/responses-runner.test.mjs +3 -1
  115. package/test/shell.test.mjs +50 -8
  116. package/test/terminal/local-pty.mock.test.mjs +151 -0
  117. /package/{src/lib → packages/agentsam-repository/src}/git-context.js +0 -0
  118. /package/{src/lib → packages/agentsam-repository/src}/merkle/diff.js +0 -0
  119. /package/{src/lib → packages/agentsam-repository/src}/merkle/filemeta.js +0 -0
  120. /package/{src/lib → packages/agentsam-repository/src}/merkle/git-ignore.js +0 -0
  121. /package/{src/lib → packages/agentsam-repository/src}/merkle/hash.js +0 -0
  122. /package/{src/lib → packages/agentsam-repository/src}/merkle/semantic.js +0 -0
  123. /package/{src/lib → packages/agentsam-repository/src}/merkle/snapshot.js +0 -0
  124. /package/{src/lib → packages/agentsam-repository/src}/merkle/tree.js +0 -0
@@ -0,0 +1,83 @@
1
+ import { createBearerTokenVerifier } from './auth.js';
2
+ import { serviceError } from './job-engine.js';
3
+ import {
4
+ grpc,
5
+ KnowledgeServiceService,
6
+ authorizationFromMetadata,
7
+ decodeJobId,
8
+ decodeSubmitRequest,
9
+ encodeJob,
10
+ encodeJobEvent,
11
+ encodeRepositoryList,
12
+ isTerminalJob,
13
+ toGrpcError,
14
+ } from './grpc-codec.js';
15
+
16
+ export async function startKnowledgeGrpcServer({
17
+ engine,
18
+ token,
19
+ verifyToken,
20
+ port = 0,
21
+ host = '127.0.0.1',
22
+ credentials = grpc.ServerCredentials.createInsecure(),
23
+ } = {}) {
24
+ if (!engine) throw new Error('Knowledge job engine is required.');
25
+ const verify = verifyToken || createBearerTokenVerifier(token);
26
+ const authenticate = call => {
27
+ if (!verify(authorizationFromMetadata(call.metadata))) throw serviceError(401, 'Unauthorized.', 'UNAUTHENTICATED');
28
+ };
29
+ const unary = handler => (call, callback) => {
30
+ try {
31
+ authenticate(call);
32
+ callback(null, handler(call));
33
+ } catch (error) {
34
+ callback(toGrpcError(error));
35
+ }
36
+ };
37
+
38
+ const server = new grpc.Server();
39
+ server.addService(KnowledgeServiceService, {
40
+ listRepositories: unary(() => encodeRepositoryList(engine.listRepositories())),
41
+ submitJob: unary(call => {
42
+ const { body, idempotencyKey } = decodeSubmitRequest(call.request);
43
+ return encodeJob(engine.submitJob(body, { idempotencyKey }).job);
44
+ }),
45
+ getJob: unary(call => encodeJob(engine.getJob(decodeJobId(call.request)))),
46
+ watchJob(call) {
47
+ let unsubscribe = () => {};
48
+ let ended = false;
49
+ try {
50
+ authenticate(call);
51
+ let sequence = 0;
52
+ unsubscribe = engine.watchJob(decodeJobId(call.request), job => {
53
+ if (ended || call.cancelled) return;
54
+ call.write(encodeJobEvent(job, ++sequence));
55
+ if (isTerminalJob(job)) {
56
+ ended = true;
57
+ queueMicrotask(() => {
58
+ unsubscribe();
59
+ if (!call.cancelled) call.end();
60
+ });
61
+ }
62
+ });
63
+ call.once('cancelled', () => { ended = true; unsubscribe(); });
64
+ call.once('close', () => { ended = true; unsubscribe(); });
65
+ } catch (error) {
66
+ ended = true;
67
+ unsubscribe();
68
+ call.emit('error', toGrpcError(error));
69
+ }
70
+ },
71
+ });
72
+
73
+ const boundPort = await new Promise((resolve, reject) => {
74
+ server.bindAsync(`${host}:${port}`, credentials, (error, actualPort) => error ? reject(error) : resolve(actualPort));
75
+ });
76
+ return {
77
+ server,
78
+ address: { host, port: boundPort },
79
+ target: `${host}:${boundPort}`,
80
+ async close() { await new Promise(resolve => server.tryShutdown(resolve)); },
81
+ forceClose() { server.forceShutdown(); },
82
+ };
83
+ }
@@ -0,0 +1,248 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { fork } from 'node:child_process';
5
+ import { DatabaseSync } from 'node:sqlite';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { fingerprint, validateConfig } from '../config.js';
8
+
9
+ const defaultWorkerPath = fileURLToPath(new URL('./job-worker.js', import.meta.url));
10
+ const TERMINAL_STATUSES = new Set(['completed', 'failed']);
11
+
12
+ export function serviceError(status, message, code) {
13
+ return Object.assign(new Error(message), { status, code });
14
+ }
15
+
16
+ function decode(row) {
17
+ return row && {
18
+ id: row.id,
19
+ status: row.status,
20
+ attempts: row.attempts,
21
+ created_at: row.created_at,
22
+ updated_at: row.updated_at,
23
+ result: row.result ? JSON.parse(row.result) : null,
24
+ error: row.error,
25
+ };
26
+ }
27
+
28
+ function normalizeRepositories(repositories) {
29
+ if (!repositories || !Object.keys(repositories).length) throw new Error('Register at least one repository.');
30
+ return Object.fromEntries(Object.entries(repositories).map(([name, repo]) => {
31
+ if (!/^[a-z][a-z0-9_-]{0,47}$/.test(name)) throw new Error('Invalid repository alias.');
32
+ const root = fs.realpathSync(repo.root);
33
+ if (!fs.statSync(root).isDirectory()) throw new Error('Repository root must be a directory.');
34
+ const config = validateConfig(repo.config);
35
+ if (config.storage.driver !== 'sqlite') throw new Error('This service release uses its durable SQLite volume; Postgres is not configured by this preset.');
36
+ return [name, { root, config }];
37
+ }));
38
+ }
39
+
40
+ function normalizeRequest(body, repositories, allowEmbeddings) {
41
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw serviceError(400, 'Expected a JSON object.', 'INVALID_ARGUMENT');
42
+ const keys = ['repository', 'operation', 'scope', 'include', 'exclude', 'embed', 'semantic', 'query', 'top_k', 'token_budget', 'generation_id', 'max_inputs', 'max_characters'];
43
+ if (Object.keys(body).some(k => !keys.includes(k))) throw serviceError(400, 'Unknown job field.', 'INVALID_ARGUMENT');
44
+ const repo = Object.hasOwn(repositories, body.repository) && repositories[body.repository];
45
+ if (!repo) throw serviceError(404, 'Repository is not registered.', 'NOT_FOUND');
46
+ if (!['index', 'plan', 'search'].includes(body.operation)) throw serviceError(400, 'operation must be index, plan, or search.', 'INVALID_ARGUMENT');
47
+ for (const key of ['embed', 'semantic']) {
48
+ if (body[key] !== undefined && typeof body[key] !== 'boolean') throw serviceError(400, `${key} must be a boolean.`, 'INVALID_ARGUMENT');
49
+ }
50
+ if (((body.embed && body.operation !== 'plan') || body.semantic) && !allowEmbeddings) {
51
+ throw serviceError(403, 'Embedding calls are disabled on this service.', 'PERMISSION_DENIED');
52
+ }
53
+ if (body.exclude !== undefined && !Array.isArray(body.exclude)) throw serviceError(400, 'exclude must be an array.', 'INVALID_ARGUMENT');
54
+ if (body.operation === 'search' && (typeof body.query !== 'string' || !body.query.trim() || body.query.length > 8000)) {
55
+ throw serviceError(400, 'A query of 1..8000 characters is required.', 'INVALID_ARGUMENT');
56
+ }
57
+ const bounded = (key, fallback, min, max) => {
58
+ const value = body[key] ?? fallback;
59
+ if (!Number.isInteger(value) || value < min || value > max) throw serviceError(400, `${key} must be ${min}..${max}.`, 'INVALID_ARGUMENT');
60
+ return value;
61
+ };
62
+ const request = {
63
+ ...body,
64
+ embed: body.embed ?? false,
65
+ semantic: body.semantic ?? false,
66
+ max_inputs: bounded('max_inputs', 100, 0, 1000),
67
+ max_characters: bounded('max_characters', 200000, 0, 2000000),
68
+ top_k: bounded('top_k', 8, 1, 8),
69
+ token_budget: bounded('token_budget', 6000, 256, 6000),
70
+ };
71
+ const config = structuredClone(repo.config);
72
+ if (body.scope !== undefined) config.scope.name = body.scope;
73
+ if (body.include !== undefined) config.scope.include = body.include;
74
+ if (body.exclude !== undefined) config.scope.exclude = [...config.scope.exclude, ...body.exclude];
75
+ let checked;
76
+ try {
77
+ checked = validateConfig(config);
78
+ } catch (error) {
79
+ throw serviceError(400, error.message, 'INVALID_ARGUMENT');
80
+ }
81
+ if (checked.scope.include.some(p => !repo.config.scope.include.some(allowed => allowed === '.' || p === allowed || p.startsWith(allowed + '/')))) {
82
+ throw serviceError(403, 'Requested include is outside the registered scope.', 'PERMISSION_DENIED');
83
+ }
84
+ return { request, config: checked, root: repo.root };
85
+ }
86
+
87
+ export function createKnowledgeJobEngine({
88
+ stateDir,
89
+ repositories,
90
+ allowEmbeddings = false,
91
+ maxQueued = 32,
92
+ maxFiles = 2000,
93
+ jobTimeoutMs = 600000,
94
+ workerPath = defaultWorkerPath,
95
+ } = {}) {
96
+ const registry = normalizeRepositories(repositories);
97
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
98
+ const jobsFile = path.join(stateDir, 'jobs.sqlite');
99
+ const db = new DatabaseSync(jobsFile);
100
+ db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
101
+ CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, idem TEXT UNIQUE, digest TEXT NOT NULL,
102
+ payload TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
103
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL, result TEXT, error TEXT);
104
+ CREATE INDEX IF NOT EXISTS jobs_pending ON jobs(status, created_at);`);
105
+ fs.chmodSync(jobsFile, 0o600);
106
+ db.prepare("UPDATE jobs SET status=CASE WHEN attempts>=3 THEN 'failed' ELSE 'queued' END, error=CASE WHEN attempts>=3 THEN 'Interrupted three times; submit a new job after investigation.' ELSE NULL END WHERE status='running'").run();
107
+
108
+ let child = null;
109
+ let closing = false;
110
+ let closed = false;
111
+ let pumping = false;
112
+ const watchers = new Map();
113
+
114
+ const getRaw = id => decode(db.prepare('SELECT * FROM jobs WHERE id=?').get(id));
115
+ const publish = job => {
116
+ const listeners = watchers.get(job.id);
117
+ if (!listeners) return;
118
+ for (const listener of [...listeners]) {
119
+ try { listener(job); } catch { /* transport observers cannot break durable state transitions */ }
120
+ }
121
+ if (TERMINAL_STATUSES.has(job.status)) watchers.delete(job.id);
122
+ };
123
+ const updateStatus = (id, status, { result = null, error = null } = {}) => {
124
+ db.prepare('UPDATE jobs SET status=?,result=?,error=?,updated_at=? WHERE id=?').run(
125
+ status,
126
+ result === null ? null : JSON.stringify(result),
127
+ error,
128
+ new Date().toISOString(),
129
+ id,
130
+ );
131
+ const job = getRaw(id);
132
+ publish(job);
133
+ return job;
134
+ };
135
+
136
+ const pump = () => {
137
+ if (closing || pumping) return;
138
+ const row = db.prepare("SELECT * FROM jobs WHERE status='queued' ORDER BY created_at,rowid LIMIT 1").get();
139
+ if (!row) return;
140
+ pumping = true;
141
+ db.prepare("UPDATE jobs SET status='running',attempts=attempts+1,updated_at=? WHERE id=?").run(new Date().toISOString(), row.id);
142
+ publish(getRaw(row.id));
143
+ let response = null;
144
+ let timedOut = false;
145
+ child = fork(workerPath, [], { execArgv: [], stdio: ['ignore', 'ignore', 'ignore', 'ipc'], env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } });
146
+ const timer = setTimeout(() => { timedOut = true; child?.kill('SIGKILL'); }, jobTimeoutMs);
147
+ child.once('message', value => { response = value; });
148
+ child.once('error', () => { response = { ok: false, error: 'Could not start indexing process.' }; });
149
+ child.once('close', () => {
150
+ clearTimeout(timer);
151
+ child = null;
152
+ if (!closing) {
153
+ const ok = response?.ok && !timedOut;
154
+ updateStatus(row.id, ok ? 'completed' : 'failed', {
155
+ result: ok ? response.result : null,
156
+ error: ok ? null : timedOut ? 'Job exceeded its time limit; narrow the scope.' : response?.error || 'Indexing process exited unexpectedly.',
157
+ });
158
+ }
159
+ pumping = false;
160
+ if (!closing) setImmediate(pump);
161
+ });
162
+ const payload = JSON.parse(row.payload);
163
+ const registered = registry[payload.request.repository];
164
+ if (!registered || registered.config.repository_id !== payload.config.repository_id || fingerprint(registered.config.scope) !== payload.registered_scope || (!allowEmbeddings && ((payload.request.embed && payload.request.operation !== 'plan') || payload.request.semantic))) {
165
+ response = { ok: false, error: 'Repository registration changed; resubmit this job.' };
166
+ child.kill();
167
+ return;
168
+ }
169
+ child.send({ ...payload, root: registered.root, filename: path.join(stateDir, 'knowledge.sqlite'), maxFiles });
170
+ };
171
+
172
+ const api = {
173
+ listRepositories() {
174
+ return {
175
+ repositories: Object.entries(registry).map(([alias, repo]) => ({ alias, repository_id: repo.config.repository_id })),
176
+ embeddings_enabled: allowEmbeddings,
177
+ };
178
+ },
179
+ getJob(id) {
180
+ if (typeof id !== 'string' || !/^[a-f0-9-]{36}$/.test(id)) throw serviceError(400, 'Invalid job id.', 'INVALID_ARGUMENT');
181
+ const job = getRaw(id);
182
+ if (!job) throw serviceError(404, 'Job not found.', 'NOT_FOUND');
183
+ return job;
184
+ },
185
+ submitJob(body, { idempotencyKey } = {}) {
186
+ if (closing) throw serviceError(503, 'Service is stopping.', 'UNAVAILABLE');
187
+ const payload = normalizeRequest(body, registry, allowEmbeddings);
188
+ payload.registered_scope = fingerprint(registry[payload.request.repository].config.scope);
189
+ if (idempotencyKey !== undefined && (typeof idempotencyKey !== 'string' || !/^[\w:.-]{1,128}$/.test(idempotencyKey))) {
190
+ throw serviceError(400, 'Invalid Idempotency-Key.', 'INVALID_ARGUMENT');
191
+ }
192
+ const digest = fingerprint(payload);
193
+ const idem = idempotencyKey ? fingerprint([payload.config.repository_id, payload.registered_scope, idempotencyKey]) : null;
194
+ const previous = idem && db.prepare('SELECT * FROM jobs WHERE idem=?').get(idem);
195
+ if (previous) {
196
+ if (previous.digest !== digest) throw serviceError(409, 'Idempotency-Key already used for a different job.', 'CONFLICT');
197
+ return { job: decode(previous), created: false };
198
+ }
199
+ if (db.prepare("SELECT COUNT(*) AS n FROM jobs WHERE status IN ('queued','running')").get().n >= maxQueued) {
200
+ throw serviceError(429, 'Job queue is full; retry later with the same Idempotency-Key.', 'RESOURCE_EXHAUSTED');
201
+ }
202
+ const id = randomUUID();
203
+ const now = new Date().toISOString();
204
+ db.prepare("INSERT INTO jobs(id,idem,digest,payload,status,created_at,updated_at) VALUES(?,?,?,?,'queued',?,?)").run(
205
+ id,
206
+ idem,
207
+ digest,
208
+ JSON.stringify(payload),
209
+ now,
210
+ now,
211
+ );
212
+ const job = getRaw(id);
213
+ publish(job);
214
+ setImmediate(pump);
215
+ return { job, created: true };
216
+ },
217
+ watchJob(id, listener) {
218
+ if (typeof listener !== 'function') throw new TypeError('watchJob listener must be a function.');
219
+ const current = api.getJob(id);
220
+ listener(current);
221
+ if (TERMINAL_STATUSES.has(current.status)) return () => {};
222
+ let listeners = watchers.get(id);
223
+ if (!listeners) watchers.set(id, listeners = new Set());
224
+ listeners.add(listener);
225
+ return () => {
226
+ listeners.delete(listener);
227
+ if (!listeners.size) watchers.delete(id);
228
+ };
229
+ },
230
+ isClosing() {
231
+ return closing;
232
+ },
233
+ async close() {
234
+ if (closed) return;
235
+ closing = true;
236
+ watchers.clear();
237
+ if (child) {
238
+ const active = child;
239
+ await new Promise(resolve => { active.once('close', resolve); active.kill('SIGKILL'); });
240
+ }
241
+ db.close();
242
+ closed = true;
243
+ },
244
+ };
245
+
246
+ setImmediate(pump);
247
+ return api;
248
+ }
@@ -1,168 +1,120 @@
1
1
  import http from 'node:http';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { randomUUID, timingSafeEqual } from 'node:crypto';
5
- import { fork } from 'node:child_process';
6
- import { DatabaseSync } from 'node:sqlite';
7
4
  import { fileURLToPath } from 'node:url';
8
- import { fingerprint, validateConfig } from '../config.js';
9
-
10
- const workerPath = fileURLToPath(new URL('./job-worker.js', import.meta.url));
11
- const fail = (status, message) => Object.assign(new Error(message), { status });
12
- const decode = row => row && ({ id: row.id, status: row.status, attempts: row.attempts, created_at: row.created_at,
13
- updated_at: row.updated_at, result: row.result ? JSON.parse(row.result) : null, error: row.error });
14
-
15
- function normalizeRequest(body, repositories, allowEmbeddings) {
16
- if (!body || typeof body !== 'object' || Array.isArray(body)) throw fail(400, 'Expected a JSON object.');
17
- const keys = ['repository', 'operation', 'scope', 'include', 'exclude', 'embed', 'semantic', 'query', 'top_k', 'token_budget', 'generation_id', 'max_inputs', 'max_characters'];
18
- if (Object.keys(body).some(k => !keys.includes(k))) throw fail(400, 'Unknown job field.');
19
- const repo = Object.hasOwn(repositories, body.repository) && repositories[body.repository];
20
- if (!repo) throw fail(404, 'Repository is not registered.');
21
- if (!['index', 'plan', 'search'].includes(body.operation)) throw fail(400, 'operation must be index, plan, or search.');
22
- for (const key of ['embed', 'semantic']) if (body[key] !== undefined && typeof body[key] !== 'boolean') throw fail(400, `${key} must be a boolean.`);
23
- if (((body.embed && body.operation !== 'plan') || body.semantic) && !allowEmbeddings) throw fail(403, 'Embedding calls are disabled on this service.');
24
- if (body.exclude !== undefined && !Array.isArray(body.exclude)) throw fail(400, 'exclude must be an array.');
25
- if (body.operation === 'search' && (typeof body.query !== 'string' || !body.query.trim() || body.query.length > 8000)) throw fail(400, 'A query of 1..8000 characters is required.');
26
- const bounded = (key, fallback, min, max) => {
27
- const value = body[key] ?? fallback;
28
- if (!Number.isInteger(value) || value < min || value > max) throw fail(400, `${key} must be ${min}..${max}.`);
29
- return value;
30
- };
31
- const request = { ...body, embed: body.embed ?? false, semantic: body.semantic ?? false,
32
- max_inputs: bounded('max_inputs', 100, 0, 1000), max_characters: bounded('max_characters', 200000, 0, 2000000),
33
- top_k: bounded('top_k', 8, 1, 8), token_budget: bounded('token_budget', 6000, 256, 6000) };
34
- const config = structuredClone(repo.config);
35
- if (body.scope !== undefined) config.scope.name = body.scope;
36
- if (body.include !== undefined) config.scope.include = body.include;
37
- if (body.exclude !== undefined) config.scope.exclude = [...config.scope.exclude, ...body.exclude];
38
- let checked;
39
- try { checked = validateConfig(config); } catch (error) { throw fail(400, error.message); }
40
- // Request scopes can narrow a registered repository's scope, never widen it.
41
- if (checked.scope.include.some(p => !repo.config.scope.include.some(allowed => allowed === '.' || p === allowed || p.startsWith(allowed + '/')))) throw fail(403, 'Requested include is outside the registered scope.');
42
- return { request, config: checked, root: repo.root };
43
- }
5
+ import { createBearerTokenVerifier } from './auth.js';
6
+ import { createKnowledgeJobEngine, serviceError } from './job-engine.js';
44
7
 
45
8
  async function readBody(req) {
46
- const chunks = []; let bytes = 0;
9
+ const chunks = [];
10
+ let bytes = 0;
47
11
  for await (const chunk of req) {
48
12
  bytes += chunk.length;
49
- if (bytes > 16384) throw fail(413, 'Job body exceeds 16 KiB.');
13
+ if (bytes > 16384) throw serviceError(413, 'Job body exceeds 16 KiB.', 'RESOURCE_EXHAUSTED');
50
14
  chunks.push(chunk);
51
15
  }
52
- try { return JSON.parse(Buffer.concat(chunks).toString()); } catch { throw fail(400, 'Invalid JSON.'); }
16
+ try {
17
+ return JSON.parse(Buffer.concat(chunks).toString());
18
+ } catch {
19
+ throw serviceError(400, 'Invalid JSON.', 'INVALID_ARGUMENT');
20
+ }
53
21
  }
54
22
 
55
- /** Trusted backend API. The calling host must authorize its user before submitting jobs. */
56
- export async function startKnowledgeService({ stateDir, repositories, token, port = 8792, host = '127.0.0.1',
57
- allowEmbeddings = false, maxQueued = 32, maxFiles = 2000, jobTimeoutMs = 600000 } = {}) {
58
- if (typeof token !== 'string' || token.length < 32 || token.length > 256) throw new Error('Service token must be 32..256 characters.');
59
- if (!repositories || !Object.keys(repositories).length) throw new Error('Register at least one repository.');
60
- repositories = Object.fromEntries(Object.entries(repositories).map(([name, repo]) => {
61
- if (!/^[a-z][a-z0-9_-]{0,47}$/.test(name)) throw new Error('Invalid repository alias.');
62
- const root = fs.realpathSync(repo.root);
63
- if (!fs.statSync(root).isDirectory()) throw new Error('Repository root must be a directory.');
64
- const config = validateConfig(repo.config);
65
- if (config.storage.driver !== 'sqlite') throw new Error('This service release uses its durable SQLite volume; Postgres is not configured by this preset.');
66
- return [name, { root, config }];
67
- }));
68
- fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
69
- const db = new DatabaseSync(path.join(stateDir, 'jobs.sqlite'));
70
- db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
71
- CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, idem TEXT UNIQUE, digest TEXT NOT NULL,
72
- payload TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
73
- created_at TEXT NOT NULL, updated_at TEXT NOT NULL, result TEXT, error TEXT);
74
- CREATE INDEX IF NOT EXISTS jobs_pending ON jobs(status, created_at);`);
75
- fs.chmodSync(path.join(stateDir, 'jobs.sqlite'), 0o600);
76
- // Interrupted work reuses SDK caches. Bound restarts so a poison job cannot loop forever.
77
- db.prepare("UPDATE jobs SET status=CASE WHEN attempts>=3 THEN 'failed' ELSE 'queued' END, error=CASE WHEN attempts>=3 THEN 'Interrupted three times; submit a new job after investigation.' ELSE NULL END WHERE status='running'").run();
78
- let child = null, closing = false, pumping = false;
79
- const get = id => decode(db.prepare('SELECT * FROM jobs WHERE id=?').get(id));
80
- const pump = () => {
81
- if (closing || pumping) return;
82
- const row = db.prepare("SELECT * FROM jobs WHERE status='queued' ORDER BY created_at,rowid LIMIT 1").get();
83
- if (!row) return;
84
- pumping = true;
85
- db.prepare("UPDATE jobs SET status='running',attempts=attempts+1,updated_at=? WHERE id=?").run(new Date().toISOString(), row.id);
86
- let response = null, timedOut = false;
87
- child = fork(workerPath, [], { execArgv: [], stdio: ['ignore', 'ignore', 'ignore', 'ipc'], env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } });
88
- const timer = setTimeout(() => { timedOut = true; child?.kill('SIGKILL'); }, jobTimeoutMs);
89
- child.once('message', value => { response = value; });
90
- child.once('error', () => { response = { ok: false, error: 'Could not start indexing process.' }; });
91
- child.once('close', () => {
92
- clearTimeout(timer); child = null;
93
- if (!closing) {
94
- const ok = response?.ok && !timedOut;
95
- db.prepare('UPDATE jobs SET status=?,result=?,error=?,updated_at=? WHERE id=?').run(ok ? 'completed' : 'failed', ok ? JSON.stringify(response.result) : null,
96
- ok ? null : timedOut ? 'Job exceeded its time limit; narrow the scope.' : response?.error || 'Indexing process exited unexpectedly.', new Date().toISOString(), row.id);
97
- }
98
- pumping = false;
99
- if (!closing) setImmediate(pump);
100
- });
101
- // Re-resolve registry on replay. Removed repositories do not get resumed.
102
- const payload = JSON.parse(row.payload), registered = repositories[payload.request.repository];
103
- if (!registered || registered.config.repository_id !== payload.config.repository_id || fingerprint(registered.config.scope) !== payload.registered_scope || (!allowEmbeddings && ((payload.request.embed && payload.request.operation !== 'plan') || payload.request.semantic))) {
104
- response = { ok: false, error: 'Repository registration changed; resubmit this job.' }; child.kill(); return;
105
- }
106
- child.send({ ...payload, root: registered.root, filename: path.join(stateDir, 'knowledge.sqlite'), maxFiles });
107
- };
108
- const secret = Buffer.from(token);
23
+ export async function startKnowledgeHttpServer({ engine, token, verifyToken, port = 8792, host = '127.0.0.1' } = {}) {
24
+ if (!engine) throw new Error('Knowledge job engine is required.');
25
+ const verify = verifyToken || createBearerTokenVerifier(token);
109
26
  const server = http.createServer(async (req, res) => {
110
- const send = (status, value) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); };
27
+ const send = (status, value) => {
28
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
29
+ res.end(JSON.stringify(value));
30
+ };
111
31
  try {
112
32
  const route = new URL(req.url, 'http://local').pathname;
113
33
  if (req.method === 'GET' && route === '/healthz') return send(200, { ok: true, service: 'agentsam-knowledge', version: 1 });
114
- const provided = Buffer.from((req.headers.authorization || '').replace(/^Bearer /, ''));
115
- if (provided.length !== secret.length || !timingSafeEqual(provided, secret)) throw fail(401, 'Unauthorized.');
116
- if (req.method === 'GET' && route === '/v1/repositories') return send(200, { repositories: Object.keys(repositories), embeddings_enabled: allowEmbeddings });
117
- if (req.method === 'GET' && /^\/v1\/jobs\/[a-f0-9-]{36}$/.test(route)) {
118
- const job = get(route.split('/').pop()); if (!job) throw fail(404, 'Job not found.'); return send(200, job);
34
+ if (!verify(req.headers.authorization)) throw serviceError(401, 'Unauthorized.', 'UNAUTHENTICATED');
35
+ if (req.method === 'GET' && route === '/v1/repositories') {
36
+ const value = engine.listRepositories();
37
+ return send(200, { repositories: value.repositories.map(repo => repo.alias), embeddings_enabled: value.embeddings_enabled });
119
38
  }
120
- if (req.method !== 'POST' || route !== '/v1/jobs') throw fail(404, 'Route not found.');
121
- if (closing) throw fail(503, 'Service is stopping.');
122
- if (!(req.headers['content-type'] || '').startsWith('application/json')) throw fail(415, 'Use application/json.');
123
- const payload = normalizeRequest(await readBody(req), repositories, allowEmbeddings);
124
- payload.registered_scope = fingerprint(repositories[payload.request.repository].config.scope);
125
- const key = req.headers['idempotency-key'];
126
- if (key !== undefined && (typeof key !== 'string' || !/^[\w:.-]{1,128}$/.test(key))) throw fail(400, 'Invalid Idempotency-Key.');
127
- const digest = fingerprint(payload), idem = key ? fingerprint([payload.config.repository_id, payload.registered_scope, key]) : null;
128
- const previous = idem && db.prepare('SELECT * FROM jobs WHERE idem=?').get(idem);
129
- if (previous) {
130
- if (previous.digest !== digest) throw fail(409, 'Idempotency-Key already used for a different job.');
131
- return send(200, decode(previous));
39
+ if (req.method === 'GET' && /^\/v1\/jobs\/[a-f0-9-]{36}$/.test(route)) {
40
+ return send(200, engine.getJob(route.split('/').pop()));
132
41
  }
133
- if (db.prepare("SELECT COUNT(*) AS n FROM jobs WHERE status IN ('queued','running')").get().n >= maxQueued) throw fail(429, 'Job queue is full; retry later with the same Idempotency-Key.');
134
- const id = randomUUID(), now = new Date().toISOString();
135
- db.prepare("INSERT INTO jobs(id,idem,digest,payload,status,created_at,updated_at) VALUES(?,?,?,?,'queued',?,?)").run(id, idem, digest, JSON.stringify(payload), now, now);
136
- send(202, get(id)); setImmediate(pump);
137
- } catch (error) { if (!res.writableEnded) send(error.status || 500, { error: error.status ? error.message : 'Service request failed.' }); }
42
+ if (req.method !== 'POST' || route !== '/v1/jobs') throw serviceError(404, 'Route not found.', 'NOT_FOUND');
43
+ if (!(req.headers['content-type'] || '').startsWith('application/json')) throw serviceError(415, 'Use application/json.', 'INVALID_ARGUMENT');
44
+ const { job, created } = engine.submitJob(await readBody(req), { idempotencyKey: req.headers['idempotency-key'] });
45
+ return send(created ? 202 : 200, job);
46
+ } catch (error) {
47
+ if (!res.writableEnded) send(error.status || 500, { error: error.status ? error.message : 'Service request failed.' });
48
+ }
138
49
  });
139
- server.requestTimeout = 15000; server.headersTimeout = 10000;
50
+ server.requestTimeout = 15000;
51
+ server.headersTimeout = 10000;
140
52
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
141
- setImmediate(pump);
142
- return { server, address: server.address(), async close() {
143
- closing = true;
144
- if (child) { const active = child; await new Promise(resolve => { active.once('close', resolve); active.kill('SIGKILL'); }); }
145
- await new Promise(resolve => server.close(resolve)); db.close();
146
- } };
53
+ return {
54
+ server,
55
+ address: server.address(),
56
+ async close() {
57
+ await new Promise(resolve => server.close(resolve));
58
+ },
59
+ };
60
+ }
61
+
62
+ /** Trusted backend API. The calling host must authorize its user before submitting jobs. */
63
+ export async function startKnowledgeService({ token, port = 8792, host = '127.0.0.1', ...engineOptions } = {}) {
64
+ const verifyToken = createBearerTokenVerifier(token);
65
+ const engine = createKnowledgeJobEngine(engineOptions);
66
+ try {
67
+ const httpService = await startKnowledgeHttpServer({ engine, verifyToken, port, host });
68
+ return {
69
+ ...httpService,
70
+ engine,
71
+ async close() {
72
+ await httpService.close();
73
+ await engine.close();
74
+ },
75
+ };
76
+ } catch (error) {
77
+ await engine.close();
78
+ throw error;
79
+ }
147
80
  }
148
81
 
149
82
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
150
83
  try {
151
- // Initialize the named volume, then match the host file owner before reading mounts.
152
- // The long-running server and all indexing children run without root privileges.
153
84
  if (process.getuid?.() === 0 && process.env.AGENTSAM_RUNTIME_UID) {
154
- const uid = Number(process.env.AGENTSAM_RUNTIME_UID), gid = Number(process.env.AGENTSAM_RUNTIME_GID);
85
+ const uid = Number(process.env.AGENTSAM_RUNTIME_UID);
86
+ const gid = Number(process.env.AGENTSAM_RUNTIME_GID);
155
87
  if (!Number.isInteger(uid) || uid < 0 || !Number.isInteger(gid) || gid < 0) throw new Error('Invalid runtime uid/gid.');
156
88
  const state = process.env.AGENTSAM_STATE_DIR || '/data';
157
- fs.mkdirSync(state, { recursive: true, mode: 0o700 }); fs.chownSync(state, uid, gid);
158
- process.setgroups([]); process.setgid(gid); process.setuid(uid);
89
+ fs.mkdirSync(state, { recursive: true, mode: 0o700 });
90
+ fs.chownSync(state, uid, gid);
91
+ process.setgroups([]);
92
+ process.setgid(gid);
93
+ process.setuid(uid);
159
94
  }
160
95
  const registry = JSON.parse(fs.readFileSync(process.env.AGENTSAM_REPOSITORIES_FILE || '/config/repositories.json', 'utf8'));
161
96
  const token = fs.readFileSync(process.env.AGENTSAM_SERVICE_TOKEN_FILE || '/config/service.token', 'utf8').trim();
162
- const service = await startKnowledgeService({ stateDir: process.env.AGENTSAM_STATE_DIR || '/data', repositories: registry,
163
- token, host: process.env.HOST || '0.0.0.0', port: Number(process.env.PORT || 8792), allowEmbeddings: process.env.AGENTSAM_ALLOW_EMBEDDINGS === 'true' });
97
+ const service = await startKnowledgeService({
98
+ stateDir: process.env.AGENTSAM_STATE_DIR || '/data',
99
+ repositories: registry,
100
+ token,
101
+ host: process.env.HOST || '0.0.0.0',
102
+ port: Number(process.env.PORT || 8792),
103
+ allowEmbeddings: process.env.AGENTSAM_ALLOW_EMBEDDINGS === 'true',
104
+ });
164
105
  console.log(JSON.stringify({ event: 'listening', port: service.address.port, repositories: Object.keys(registry), embeddings_enabled: process.env.AGENTSAM_ALLOW_EMBEDDINGS === 'true' }));
165
106
  let stopped = false;
166
- for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, async () => { if (!stopped) { stopped = true; await service.close(); process.exit(0); } });
167
- } catch { console.error('Knowledge service could not start; check repository registrations, token file, and state permissions.'); process.exit(1); }
107
+ for (const signal of ['SIGTERM', 'SIGINT']) {
108
+ process.on(signal, async () => {
109
+ if (!stopped) {
110
+ stopped = true;
111
+ await service.close();
112
+ process.exit(0);
113
+ }
114
+ });
115
+ }
116
+ } catch {
117
+ console.error('Knowledge service could not start; check repository registrations, token file, and state permissions.');
118
+ process.exit(1);
119
+ }
168
120
  }
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import ts from 'typescript';
5
- import { fileHasher } from '../lib/merkle/hash.js';
5
+ import { fileHasher } from '../../packages/agentsam-repository/src/merkle/hash.js';
6
6
  import { fingerprint } from './config.js';
7
7
 
8
8
  export const PARSER = `typescript:${ts.version}:agentsam-1`;