@grackle-ai/core 0.83.0 → 0.83.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 (64) hide show
  1. package/dist/codespace-handlers.d.ts +6 -0
  2. package/dist/codespace-handlers.d.ts.map +1 -0
  3. package/dist/codespace-handlers.js +66 -0
  4. package/dist/codespace-handlers.js.map +1 -0
  5. package/dist/environment-handlers.d.ts +16 -0
  6. package/dist/environment-handlers.d.ts.map +1 -0
  7. package/dist/environment-handlers.js +237 -0
  8. package/dist/environment-handlers.js.map +1 -0
  9. package/dist/finding-handlers.d.ts +6 -0
  10. package/dist/finding-handlers.d.ts.map +1 -0
  11. package/dist/finding-handlers.js +27 -0
  12. package/dist/finding-handlers.js.map +1 -0
  13. package/dist/grpc-mcp-config.d.ts +11 -0
  14. package/dist/grpc-mcp-config.d.ts.map +1 -0
  15. package/dist/grpc-mcp-config.js +29 -0
  16. package/dist/grpc-mcp-config.js.map +1 -0
  17. package/dist/grpc-proto-converters.d.ts +25 -0
  18. package/dist/grpc-proto-converters.d.ts.map +1 -0
  19. package/dist/grpc-proto-converters.js +193 -0
  20. package/dist/grpc-proto-converters.js.map +1 -0
  21. package/dist/grpc-service.d.ts +3 -19
  22. package/dist/grpc-service.d.ts.map +1 -1
  23. package/dist/grpc-service.js +28 -2018
  24. package/dist/grpc-service.js.map +1 -1
  25. package/dist/grpc-shared.d.ts +25 -0
  26. package/dist/grpc-shared.d.ts.map +1 -0
  27. package/dist/grpc-shared.js +110 -0
  28. package/dist/grpc-shared.js.map +1 -0
  29. package/dist/knowledge-handlers.d.ts +12 -0
  30. package/dist/knowledge-handlers.d.ts.map +1 -0
  31. package/dist/knowledge-handlers.js +85 -0
  32. package/dist/knowledge-handlers.js.map +1 -0
  33. package/dist/persona-handlers.d.ts +12 -0
  34. package/dist/persona-handlers.d.ts.map +1 -0
  35. package/dist/persona-handlers.js +148 -0
  36. package/dist/persona-handlers.js.map +1 -0
  37. package/dist/schedule-handlers.d.ts +12 -0
  38. package/dist/schedule-handlers.d.ts.map +1 -0
  39. package/dist/schedule-handlers.js +122 -0
  40. package/dist/schedule-handlers.js.map +1 -0
  41. package/dist/session-handlers.d.ts +34 -0
  42. package/dist/session-handlers.d.ts.map +1 -0
  43. package/dist/session-handlers.js +488 -0
  44. package/dist/session-handlers.js.map +1 -0
  45. package/dist/settings-handlers.d.ts +10 -0
  46. package/dist/settings-handlers.d.ts.map +1 -0
  47. package/dist/settings-handlers.js +68 -0
  48. package/dist/settings-handlers.js.map +1 -0
  49. package/dist/signals/sigchld.d.ts.map +1 -1
  50. package/dist/signals/sigchld.js +4 -8
  51. package/dist/signals/sigchld.js.map +1 -1
  52. package/dist/task-handlers.d.ts +22 -0
  53. package/dist/task-handlers.d.ts.map +1 -0
  54. package/dist/task-handlers.js +463 -0
  55. package/dist/task-handlers.js.map +1 -0
  56. package/dist/token-handlers.d.ts +12 -0
  57. package/dist/token-handlers.d.ts.map +1 -0
  58. package/dist/token-handlers.js +85 -0
  59. package/dist/token-handlers.js.map +1 -0
  60. package/dist/workspace-handlers.d.ts +12 -0
  61. package/dist/workspace-handlers.d.ts.map +1 -0
  62. package/dist/workspace-handlers.js +86 -0
  63. package/dist/workspace-handlers.js.map +1 -0
  64. package/package.json +7 -7
@@ -1,2024 +1,34 @@
1
- import { ConnectError, Code } from "@connectrpc/connect";
2
- import { create } from "@bufbuild/protobuf";
3
- import { grackle, powerline } from "@grackle-ai/common";
4
- import { v4 as uuid } from "uuid";
5
- import { envRegistry, sessionStore, tokenStore, workspaceStore, taskStore, findingStore, personaStore, settingsStore, scheduleStore, isAllowedSettingKey, credentialProviders, grackleHome, safeParseJsonArray, slugify } from "@grackle-ai/database";
6
- import * as adapterManager from "./adapter-manager.js";
7
- import { reconnectOrProvision } from "@grackle-ai/adapter-sdk";
8
- import * as streamHub from "./stream-hub.js";
9
- import * as tokenPush from "./token-push.js";
10
- import { parseAdapterConfig } from "./adapter-config.js";
11
- import { emit } from "./event-bus.js";
12
- import { processEventStream } from "./event-processor.js";
13
- import * as processorRegistry from "./processor-registry.js";
14
- import { recoverSuspendedSessions } from "./session-recovery.js";
15
- import { clearReconnectState } from "./auto-reconnect.js";
16
- import { checkVersionStatus } from "./version-check.js";
17
- import { createEventStream } from "./event-hub.js";
18
- import { join } from "node:path";
19
- import { LOGS_DIR, DEFAULT_WEB_PORT, DEFAULT_MCP_PORT, MAX_TASK_DEPTH, SESSION_STATUS, TERMINAL_SESSION_STATUSES, END_REASON, TASK_STATUS, ROOT_TASK_ID, ROOT_TASK_INITIAL_PROMPT, taskStatusToEnum, taskStatusToString, workspaceStatusToEnum, claudeProviderModeToEnum, providerToggleToEnum, eventTypeToEnum, ALL_MCP_TOOL_NAMES, } from "@grackle-ai/common";
20
- import * as logWriter from "./log-writer.js";
21
- import { resolvePersona, fetchOrchestratorContext, SystemPromptBuilder, buildTaskPrompt } from "@grackle-ai/prompt";
22
- import { validateExpression, computeNextRunAt } from "./schedule-expression.js";
23
- import { createScopedToken, loadOrCreateApiKey, generatePairingCode } from "@grackle-ai/auth";
24
- import { computeTaskStatus } from "./compute-task-status.js";
25
- import { logger } from "./logger.js";
26
- import { reanimateAgent } from "./reanimate-agent.js";
27
- import { getKnowledgeEmbedder, isKnowledgeEnabled } from "./knowledge-init.js";
28
- import { knowledgeSearch, getNode as getKnowledgeNodeById, expandNode, createNativeNode, ingest, createPassThroughChunker, listRecentNodes, } from "@grackle-ai/knowledge";
29
- import { exec } from "./utils/exec.js";
30
- import { formatGhError } from "./utils/format-gh-error.js";
31
- import { detectLanIp } from "./utils/network.js";
32
- import * as streamRegistry from "./stream-registry.js";
33
- import * as pipeDelivery from "./pipe-delivery.js";
34
- import { ensureAsyncDeliveryListener } from "./pipe-delivery.js";
35
- import { cleanupLifecycleStream, ensureLifecycleStream } from "./lifecycle.js";
36
- import { sendInputToSession } from "./signals/signal-delivery.js";
37
- import { transferAllPipeSubscriptions } from "./signals/orphan-reparent.js";
38
- /** Valid pipe mode values for SpawnRequest and StartTaskRequest. */
39
- const VALID_PIPE_MODES = new Set(["", "sync", "async", "detach"]);
40
- /** Timeout for `gh codespace list` in milliseconds. */
41
- const GH_CODESPACE_LIST_TIMEOUT_MS = 30_000;
42
- /** Timeout for `gh codespace create` in milliseconds. */
43
- const GH_CODESPACE_CREATE_TIMEOUT_MS = 300_000;
44
- /** Maximum number of codespaces returned by `gh codespace list`. */
45
- const GH_CODESPACE_LIST_LIMIT = 50;
46
- /** Validate pipe mode and parentSessionId. Throws ConnectError on invalid input. */
47
- function validatePipeInputs(pipe, parentSessionId) {
48
- if (pipe && !VALID_PIPE_MODES.has(pipe)) {
49
- throw new ConnectError(`Invalid pipe mode: "${pipe}". Must be "sync", "async", "detach", or empty.`, Code.InvalidArgument);
50
- }
51
- if (pipe && pipe !== "detach" && !parentSessionId) {
52
- throw new ConnectError(`Pipe mode "${pipe}" requires parent_session_id`, Code.InvalidArgument);
53
- }
54
- }
55
- /**
56
- * Map a bind host to a dialable URL host. Wildcard addresses become loopback,
57
- * unless GRACKLE_DOCKER_HOST is set (DooD mode) — in that case, use that value
58
- * so sibling containers can reach the server by container name.
59
- */
60
- export function toDialableHost(bindHost) {
61
- if (bindHost === "0.0.0.0" || bindHost === "::") {
62
- const dockerHost = process.env.GRACKLE_DOCKER_HOST;
63
- if (dockerHost) {
64
- if (dockerHost.startsWith("[") && dockerHost.endsWith("]")) {
65
- return dockerHost;
66
- }
67
- return dockerHost.includes(":") ? `[${dockerHost}]` : dockerHost;
68
- }
69
- return bindHost === "::" ? "[::1]" : "127.0.0.1";
70
- }
71
- return bindHost.includes(":") ? `[${bindHost}]` : bindHost;
72
- }
73
- function envRowToProto(row) {
74
- return create(grackle.EnvironmentSchema, {
75
- id: row.id,
76
- displayName: row.displayName,
77
- adapterType: row.adapterType,
78
- adapterConfig: row.adapterConfig,
79
- bootstrapped: row.bootstrapped,
80
- status: row.status,
81
- lastSeen: row.lastSeen || "",
82
- envInfo: row.envInfo || "",
83
- createdAt: row.createdAt,
84
- });
85
- }
86
- function sessionRowToProto(row) {
87
- return create(grackle.SessionSchema, {
88
- id: row.id,
89
- environmentId: row.environmentId,
90
- runtime: row.runtime,
91
- runtimeSessionId: row.runtimeSessionId ?? "",
92
- prompt: row.prompt,
93
- model: row.model,
94
- status: row.status,
95
- logPath: row.logPath ?? "",
96
- turns: row.turns,
97
- startedAt: row.startedAt,
98
- suspendedAt: row.suspendedAt ?? "",
99
- endedAt: row.endedAt ?? "",
100
- error: row.error ?? "",
101
- taskId: row.taskId,
102
- personaId: row.personaId,
103
- inputTokens: row.inputTokens,
104
- outputTokens: row.outputTokens,
105
- costUsd: row.costUsd,
106
- endReason: row.endReason ?? "",
107
- });
108
- }
109
- function workspaceRowToProto(row) {
110
- return create(grackle.WorkspaceSchema, {
111
- id: row.id,
112
- name: row.name,
113
- description: row.description,
114
- repoUrl: row.repoUrl,
115
- environmentId: row.environmentId,
116
- status: workspaceStatusToEnum(row.status),
117
- createdAt: row.createdAt,
118
- updatedAt: row.updatedAt,
119
- useWorktrees: row.useWorktrees,
120
- workingDirectory: row.workingDirectory,
121
- defaultPersonaId: row.defaultPersonaId,
122
- });
123
- }
124
- function taskRowToProto(row, childIds, computedStatus, latestSessionId) {
125
- return create(grackle.TaskSchema, {
126
- id: row.id,
127
- workspaceId: row.workspaceId ?? undefined,
128
- title: row.title,
129
- description: row.description,
130
- status: taskStatusToEnum(computedStatus ?? row.status),
131
- branch: row.branch,
132
- latestSessionId: latestSessionId ?? "",
133
- dependsOn: safeParseJsonArray(row.dependsOn),
134
- startedAt: row.startedAt ?? "",
135
- completedAt: row.completedAt ?? "",
136
- createdAt: row.createdAt,
137
- updatedAt: row.updatedAt,
138
- sortOrder: row.sortOrder,
139
- parentTaskId: row.parentTaskId,
140
- depth: row.depth,
141
- childTaskIds: childIds ?? taskStore.getChildren(row.id).map((c) => c.id),
142
- canDecompose: row.canDecompose,
143
- defaultPersonaId: row.defaultPersonaId,
144
- workpad: row.workpad,
145
- scheduleId: row.scheduleId,
146
- });
147
- }
148
- function scheduleRowToProto(row) {
149
- return create(grackle.ScheduleSchema, {
150
- id: row.id,
151
- title: row.title,
152
- description: row.description,
153
- scheduleExpression: row.scheduleExpression,
154
- personaId: row.personaId,
155
- environmentId: row.environmentId,
156
- workspaceId: row.workspaceId,
157
- parentTaskId: row.parentTaskId,
158
- enabled: row.enabled,
159
- lastRunAt: row.lastRunAt ?? "",
160
- nextRunAt: row.nextRunAt ?? "",
161
- runCount: row.runCount,
162
- createdAt: row.createdAt,
163
- updatedAt: row.updatedAt,
164
- });
165
- }
166
- function findingRowToProto(row) {
167
- return create(grackle.FindingSchema, {
168
- ...row,
169
- tags: safeParseJsonArray(row.tags),
170
- });
171
- }
172
- /** Safely parse a JSON string, returning the fallback value on failure. */
173
- function safeParseJson(value, fallback) {
174
- if (!value) {
175
- return fallback;
176
- }
177
- try {
178
- return JSON.parse(value);
179
- }
180
- catch {
181
- return fallback;
182
- }
183
- }
184
- /** Convert a persona database row to a Persona proto message. */
185
- function personaRowToProto(row) {
186
- const toolConfig = safeParseJson(row.toolConfig, {});
187
- const mcpServers = safeParseJson(row.mcpServers, []);
188
- return create(grackle.PersonaSchema, {
189
- id: row.id,
190
- name: row.name,
191
- description: row.description,
192
- systemPrompt: row.systemPrompt,
193
- toolConfig: create(grackle.ToolConfigSchema, {
194
- allowedTools: Array.isArray(toolConfig.allowedTools)
195
- ? toolConfig.allowedTools.filter((t) => typeof t === "string")
196
- : [],
197
- disallowedTools: Array.isArray(toolConfig.disallowedTools)
198
- ? toolConfig.disallowedTools.filter((t) => typeof t === "string")
199
- : [],
200
- }),
201
- runtime: row.runtime,
202
- model: row.model,
203
- maxTurns: row.maxTurns,
204
- mcpServers: mcpServers
205
- .filter((s) =>
206
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typeof null === "object", JSON.parse can return null
207
- s !== null &&
208
- typeof s === "object" &&
209
- typeof s.name === "string" &&
210
- typeof s.command === "string")
211
- .map((s) => create(grackle.McpServerConfigSchema, {
212
- name: s.name,
213
- command: s.command,
214
- args: Array.isArray(s.args)
215
- ? s.args.filter((a) => typeof a === "string")
216
- : [],
217
- tools: Array.isArray(s.tools)
218
- ? s.tools.filter((t) => typeof t === "string")
219
- : [],
220
- })),
221
- createdAt: row.createdAt,
222
- updatedAt: row.updatedAt,
223
- type: row.type || "agent",
224
- script: row.script || "",
225
- allowedMcpTools: safeParseJson(row.allowedMcpTools, []).filter((t) => typeof t === "string"),
226
- });
227
- }
228
- /** Convert persona MCP server configs to a JSON string for the PowerLine SpawnRequest. */
229
- function personaMcpServersToJson(row) {
230
- let mcpServers;
231
- try {
232
- mcpServers = JSON.parse(row.mcpServers || "[]");
233
- }
234
- catch {
235
- logger.warn({ personaId: row.id }, "Failed to parse persona mcpServers JSON; ignoring");
236
- return "";
237
- }
238
- if (!Array.isArray(mcpServers) || mcpServers.length === 0) {
239
- return "";
240
- }
241
- return buildMcpServersJson(mcpServers);
242
- }
243
- /** Build a JSON string of MCP server configs for the PowerLine SpawnRequest. */
244
- export function buildMcpServersJson(mcpServers) {
245
- const obj = {};
246
- for (const s of mcpServers) {
247
- obj[s.name] = {
248
- command: s.command,
249
- args: s.args || [],
250
- ...(s.tools && s.tools.length > 0 ? { tools: s.tools } : {}),
251
- };
252
- }
253
- return JSON.stringify(obj);
254
- }
255
- /**
256
- * Walk up the task parent chain and return the environmentId from the first
257
- * ancestor that has a session. Returns empty string if no ancestor has one.
258
- */
259
- export function resolveAncestorEnvironmentId(parentTaskId) {
260
- let currentId = parentTaskId;
261
- for (let i = 0; i < MAX_TASK_DEPTH && currentId; i++) {
262
- const session = sessionStore.getLatestSessionForTask(currentId);
263
- if (session?.environmentId) {
264
- return session.environmentId;
265
- }
266
- const parent = taskStore.getTask(currentId);
267
- if (!parent) {
268
- break;
269
- }
270
- currentId = parent.parentTaskId;
271
- }
272
- return "";
273
- }
274
- /**
275
- * Terminate a session and clean up all associated streams and subscriptions.
276
- *
277
- * If the session is already in a terminal state the status update is skipped,
278
- * but lifecycle and subscription streams are always removed so stale handles
279
- * do not accumulate.
280
- */
281
- function killSessionAndCleanup(session) {
282
- if (!TERMINAL_SESSION_STATUSES.has(session.status)) {
283
- sessionStore.updateSession(session.id, SESSION_STATUS.STOPPED, undefined, undefined, END_REASON.KILLED);
284
- streamHub.publish(create(grackle.SessionEventSchema, {
285
- sessionId: session.id,
286
- type: grackle.EventType.STATUS,
287
- timestamp: new Date().toISOString(),
288
- content: END_REASON.KILLED,
289
- raw: "",
290
- }));
291
- if (session.taskId) {
292
- const task = taskStore.getTask(session.taskId);
293
- if (task) {
294
- emit("task.updated", { taskId: task.id, workspaceId: task.workspaceId || "" });
295
- }
296
- }
297
- }
298
- // Forward kill to PowerLine so the agent process is actually terminated.
299
- // The orphan callback also sends a kill, but that fires asynchronously
300
- // after subscription cleanup — this ensures immediate process termination.
301
- const conn = adapterManager.getConnection(session.environmentId);
302
- if (conn) {
303
- conn.client.kill(create(powerline.KillRequestSchema, { id: session.id, reason: END_REASON.KILLED })).catch((err) => {
304
- logger.debug({ err, sessionId: session.id }, "PowerLine kill failed (process may have already exited)");
305
- });
306
- }
307
- // Transfer ALL pipe fds to grandparent BEFORE cleaning up subscriptions.
308
- // Always transfer regardless of orphaned tasks: ipc_spawn creates child sessions
309
- // (not tasks), so pipe subs exist even when getOrphanedTasks returns empty.
310
- if (session.taskId) {
311
- const task = taskStore.getTask(session.taskId);
312
- if (task) {
313
- const grandparentId = task.parentTaskId || ROOT_TASK_ID;
314
- transferAllPipeSubscriptions(task.id, grandparentId);
315
- }
316
- }
317
- cleanupLifecycleStream(session.id);
318
- const subs = streamRegistry.getSubscriptionsForSession(session.id);
319
- for (const sub of subs) {
320
- streamRegistry.unsubscribe(sub.id);
321
- }
322
- }
1
+ import { grackle } from "@grackle-ai/common";
2
+ import * as environments from "./environment-handlers.js";
3
+ import * as sessions from "./session-handlers.js";
4
+ import * as tasks from "./task-handlers.js";
5
+ import * as workspaces from "./workspace-handlers.js";
6
+ import * as personas from "./persona-handlers.js";
7
+ import * as schedules from "./schedule-handlers.js";
8
+ import * as tokens from "./token-handlers.js";
9
+ import * as findings from "./finding-handlers.js";
10
+ import * as codespaces from "./codespace-handlers.js";
11
+ import * as knowledge from "./knowledge-handlers.js";
12
+ import * as settings from "./settings-handlers.js";
13
+ // Re-export shared helpers that existing test files
14
+ // (to-dialable-host.test.ts, resolve-ancestor-env.test.ts)
15
+ // import directly from this module.
16
+ export { toDialableHost, resolveAncestorEnvironmentId } from "./grpc-shared.js";
17
+ export { buildMcpServersJson } from "./grpc-mcp-config.js";
323
18
  /** Register all Grackle gRPC service handlers on the given ConnectRPC router. */
324
19
  export function registerGrackleRoutes(router) {
325
20
  router.service(grackle.Grackle, {
326
- async listEnvironments() {
327
- const rows = envRegistry.listEnvironments();
328
- return create(grackle.EnvironmentListSchema, {
329
- environments: rows.map(envRowToProto),
330
- });
331
- },
332
- async addEnvironment(req) {
333
- if (!req.displayName || !req.adapterType) {
334
- throw new ConnectError("displayName and adapterType required", Code.InvalidArgument);
335
- }
336
- const id = req.displayName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
337
- envRegistry.addEnvironment(id, req.displayName, req.adapterType, req.adapterConfig);
338
- emit("environment.changed", {});
339
- const row = envRegistry.getEnvironment(id);
340
- return envRowToProto(row);
341
- },
342
- async updateEnvironment(req) {
343
- if (!req.id) {
344
- throw new ConnectError("id is required", Code.InvalidArgument);
345
- }
346
- const existing = envRegistry.getEnvironment(req.id);
347
- if (!existing) {
348
- throw new ConnectError(`Environment not found: ${req.id}`, Code.NotFound);
349
- }
350
- const displayName = req.displayName !== undefined ? req.displayName : undefined;
351
- if (displayName?.trim() === "") {
352
- throw new ConnectError("Environment name cannot be empty", Code.InvalidArgument);
353
- }
354
- let adapterConfig;
355
- if (req.adapterConfig !== undefined) {
356
- const raw = req.adapterConfig.trim() || "{}";
357
- let parsed;
358
- try {
359
- parsed = JSON.parse(raw);
360
- }
361
- catch {
362
- throw new ConnectError("adapterConfig is not valid JSON", Code.InvalidArgument);
363
- }
364
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
365
- throw new ConnectError("adapterConfig must be a JSON object", Code.InvalidArgument);
366
- }
367
- adapterConfig = raw;
368
- }
369
- const trimmedName = displayName !== undefined ? displayName.trim() : undefined;
370
- if (trimmedName === undefined && adapterConfig === undefined) {
371
- throw new ConnectError("No updatable fields provided", Code.InvalidArgument);
372
- }
373
- envRegistry.updateEnvironment(req.id, {
374
- displayName: trimmedName,
375
- adapterConfig,
376
- });
377
- logger.info({ environmentId: req.id, displayName: trimmedName }, "Environment updated");
378
- emit("environment.changed", {});
379
- const updated = envRegistry.getEnvironment(req.id);
380
- return envRowToProto(updated);
381
- },
382
- async removeEnvironment(req) {
383
- // Block deletion if workspaces still reference this environment
384
- const wsCount = workspaceStore.countWorkspacesByEnvironment(req.id);
385
- if (wsCount > 0) {
386
- throw new ConnectError(`Cannot remove environment: ${wsCount} active workspace(s) still reference it. Archive or reparent them first.`, Code.FailedPrecondition);
387
- }
388
- // Stop auto-reconnect attempts for this environment
389
- clearReconnectState(req.id);
390
- // Disconnect the adapter if currently connected
391
- const env = envRegistry.getEnvironment(req.id);
392
- if (env) {
393
- const adapter = adapterManager.getAdapter(env.adapterType);
394
- if (adapter) {
395
- try {
396
- await adapter.disconnect(req.id);
397
- }
398
- catch {
399
- /* best-effort */
400
- }
401
- }
402
- }
403
- adapterManager.removeConnection(req.id);
404
- // Delete sessions referencing this environment (FK constraint)
405
- sessionStore.deleteByEnvironment(req.id);
406
- envRegistry.removeEnvironment(req.id);
407
- emit("environment.changed", {});
408
- emit("environment.removed", { environmentId: req.id });
409
- return create(grackle.EmptySchema, {});
410
- },
411
- async *provisionEnvironment(req) {
412
- // Manual provision overrides auto-reconnect
413
- clearReconnectState(req.id);
414
- const env = envRegistry.getEnvironment(req.id);
415
- if (!env) {
416
- yield create(grackle.ProvisionEventSchema, {
417
- stage: "error",
418
- message: `Environment not found: ${req.id}`,
419
- progress: 0,
420
- });
421
- return;
422
- }
423
- const adapter = adapterManager.getAdapter(env.adapterType);
424
- if (!adapter) {
425
- yield create(grackle.ProvisionEventSchema, {
426
- stage: "error",
427
- message: `No adapter for type: ${env.adapterType}`,
428
- progress: 0,
429
- });
430
- return;
431
- }
432
- // Force teardown: kill active session, disconnect adapter, clear connection
433
- if (req.force) {
434
- const activeSession = sessionStore.getActiveForEnv(req.id);
435
- if (activeSession) {
436
- killSessionAndCleanup(activeSession);
437
- }
438
- try {
439
- await adapter.disconnect(req.id);
440
- }
441
- catch {
442
- // best-effort teardown
443
- }
444
- adapterManager.removeConnection(req.id);
445
- }
446
- envRegistry.updateEnvironmentStatus(req.id, "connecting");
447
- emit("environment.changed", {});
448
- const config = parseAdapterConfig(env.adapterConfig);
449
- config.defaultRuntime = env.defaultRuntime;
450
- const powerlineToken = env.powerlineToken;
451
- try {
452
- for await (const event of reconnectOrProvision(req.id, adapter, config, powerlineToken, !!env.bootstrapped, req.force)) {
453
- yield create(grackle.ProvisionEventSchema, {
454
- stage: event.stage,
455
- message: event.message,
456
- progress: event.progress,
457
- });
458
- }
459
- }
460
- catch (err) {
461
- logger.error({ environmentId: req.id, err }, "Provision/bootstrap failed");
462
- const currentEnv = envRegistry.getEnvironment(req.id);
463
- if (currentEnv?.status !== "connected") {
464
- envRegistry.updateEnvironmentStatus(req.id, "error");
465
- emit("environment.changed", {});
466
- }
467
- yield create(grackle.ProvisionEventSchema, {
468
- stage: "error",
469
- message: `Provision failed: ${err instanceof Error ? err.message : String(err)}`,
470
- progress: 0,
471
- });
472
- return;
473
- }
474
- try {
475
- const conn = await adapter.connect(req.id, config, powerlineToken);
476
- adapterManager.setConnection(req.id, conn);
477
- // Push stored tokens to newly connected environment
478
- await tokenPush.pushToEnv(req.id);
479
- envRegistry.updateEnvironmentStatus(req.id, "connected");
480
- envRegistry.markBootstrapped(req.id);
481
- emit("environment.changed", {});
482
- // Auto-recover suspended sessions (fire-and-forget)
483
- recoverSuspendedSessions(req.id, conn).catch((err) => {
484
- logger.error({ environmentId: req.id, err }, "Session recovery failed");
485
- });
486
- }
487
- catch (err) {
488
- // adapter.connect() actually failed
489
- envRegistry.updateEnvironmentStatus(req.id, "error");
490
- emit("environment.changed", {});
491
- yield create(grackle.ProvisionEventSchema, {
492
- stage: "error",
493
- message: `Connection failed: ${err instanceof Error ? err.message : String(err)}`,
494
- progress: 0,
495
- });
496
- return;
497
- }
498
- // Best-effort: notify client that provision completed.
499
- // If the client already disconnected (e.g. fire-and-forget fetch in
500
- // test helpers), the yield throws — but the environment IS connected,
501
- // so we must NOT revert the status to "error".
502
- try {
503
- yield create(grackle.ProvisionEventSchema, {
504
- stage: "ready",
505
- message: "Environment connected",
506
- progress: 1,
507
- });
508
- }
509
- catch {
510
- // Client disconnected after successful provision — ignore
511
- }
512
- },
513
- async stopEnvironment(req) {
514
- const env = envRegistry.getEnvironment(req.id);
515
- if (!env) {
516
- throw new ConnectError(`Environment not found: ${req.id}`, Code.NotFound);
517
- }
518
- const adapter = adapterManager.getAdapter(env.adapterType);
519
- if (adapter) {
520
- await adapter.stop(req.id, parseAdapterConfig(env.adapterConfig));
521
- }
522
- adapterManager.removeConnection(req.id);
523
- envRegistry.updateEnvironmentStatus(req.id, "disconnected");
524
- emit("environment.changed", {});
525
- return create(grackle.EmptySchema, {});
526
- },
527
- async destroyEnvironment(req) {
528
- const env = envRegistry.getEnvironment(req.id);
529
- if (!env) {
530
- throw new ConnectError(`Environment not found: ${req.id}`, Code.NotFound);
531
- }
532
- const adapter = adapterManager.getAdapter(env.adapterType);
533
- if (adapter) {
534
- await adapter.destroy(req.id, parseAdapterConfig(env.adapterConfig));
535
- }
536
- adapterManager.removeConnection(req.id);
537
- envRegistry.updateEnvironmentStatus(req.id, "disconnected");
538
- emit("environment.changed", {});
539
- return create(grackle.EmptySchema, {});
540
- },
541
- async spawnAgent(req) {
542
- if (!req.environmentId) {
543
- throw new ConnectError("environment_id is required", Code.InvalidArgument);
544
- }
545
- const env = envRegistry.getEnvironment(req.environmentId);
546
- if (!env) {
547
- throw new ConnectError(`Environment not found: ${req.environmentId}`, Code.NotFound);
548
- }
549
- let conn = adapterManager.getConnection(req.environmentId);
550
- if (!conn) {
551
- // Auto-provision: attempt to reconnect/provision a disconnected environment
552
- const adapter = adapterManager.getAdapter(env.adapterType);
553
- if (!adapter) {
554
- throw new ConnectError(`No adapter for type: ${env.adapterType}`, Code.FailedPrecondition);
555
- }
556
- logger.info({ environmentId: req.environmentId }, "Auto-provisioning environment for SpawnAgent");
557
- envRegistry.updateEnvironmentStatus(req.environmentId, "connecting");
558
- emit("environment.changed", {});
559
- const config = parseAdapterConfig(env.adapterConfig);
560
- config.defaultRuntime = env.defaultRuntime;
561
- const powerlineToken = env.powerlineToken;
562
- try {
563
- for await (const provEvent of reconnectOrProvision(req.environmentId, adapter, config, powerlineToken, !!env.bootstrapped)) {
564
- logger.info({ environmentId: req.environmentId, stage: provEvent.stage }, "Auto-provision progress (SpawnAgent)");
565
- emit("environment.provision_progress", {
566
- environmentId: req.environmentId,
567
- stage: provEvent.stage,
568
- message: provEvent.message,
569
- progress: provEvent.progress,
570
- });
571
- }
572
- conn = await adapter.connect(req.environmentId, config, powerlineToken);
573
- adapterManager.setConnection(req.environmentId, conn);
574
- await tokenPush.pushToEnv(req.environmentId);
575
- envRegistry.updateEnvironmentStatus(req.environmentId, "connected");
576
- envRegistry.markBootstrapped(req.environmentId);
577
- emit("environment.changed", {});
578
- // Auto-recover suspended sessions (fire-and-forget)
579
- recoverSuspendedSessions(req.environmentId, conn).catch((err) => {
580
- logger.error({ environmentId: req.environmentId, err }, "Session recovery failed");
581
- });
582
- logger.info({ environmentId: req.environmentId }, "Auto-provision complete (SpawnAgent)");
583
- emit("environment.provision_progress", {
584
- environmentId: req.environmentId,
585
- stage: "ready",
586
- message: "Environment connected",
587
- progress: 1,
588
- });
589
- }
590
- catch (err) {
591
- logger.error({ environmentId: req.environmentId, err }, "Auto-provision failed (SpawnAgent)");
592
- envRegistry.updateEnvironmentStatus(req.environmentId, "error");
593
- emit("environment.changed", {});
594
- throw new ConnectError(`Failed to auto-connect environment ${req.environmentId}: ${err instanceof Error ? err.message : String(err)}`, Code.FailedPrecondition);
595
- }
596
- }
597
- // Resolve persona via cascade (request → app default)
598
- let resolved;
599
- try {
600
- resolved = resolvePersona(req.personaId);
601
- }
602
- catch (err) {
603
- throw new ConnectError(err.message, Code.FailedPrecondition);
604
- }
605
- const sessionId = uuid();
606
- const { runtime, model, systemPrompt, persona } = resolved;
607
- const maxTurns = req.maxTurns || resolved.maxTurns;
608
- const logPath = join(grackleHome, LOGS_DIR, sessionId);
609
- const builderPrompt = new SystemPromptBuilder({
610
- personaPrompt: systemPrompt,
611
- }).build();
612
- const systemContext = req.systemContext
613
- ? builderPrompt + "\n\n" + req.systemContext
614
- : builderPrompt;
615
- // Validate pipe inputs before creating the session or spawning the child
616
- validatePipeInputs(req.pipe, req.parentSessionId);
617
- const pipeMode = req.pipe;
618
- sessionStore.createSession(sessionId, req.environmentId, runtime, req.prompt, model, logPath, "", // taskId
619
- resolved.personaId, // personaId
620
- req.parentSessionId || "", // parentSessionId
621
- pipeMode || "");
622
- const mcpServersJson = personaMcpServersToJson(persona);
623
- const mcpPort = parseInt(process.env.GRACKLE_MCP_PORT || String(DEFAULT_MCP_PORT), 10);
624
- const mcpDialHost = toDialableHost(process.env.GRACKLE_HOST || "127.0.0.1");
625
- const mcpUrl = `http://${mcpDialHost}:${mcpPort}/mcp`;
626
- const mcpToken = createScopedToken({ sub: sessionId, pid: "", per: resolved.personaId, sid: sessionId }, loadOrCreateApiKey(grackleHome));
627
- const powerlineReq = create(powerline.SpawnRequestSchema, {
628
- sessionId,
629
- runtime,
630
- prompt: req.prompt,
631
- model,
632
- maxTurns,
633
- branch: req.branch,
634
- workingDirectory: req.branch
635
- ? (req.workingDirectory.trim() || process.env.GRACKLE_WORKING_DIRECTORY || process.env.GRACKLE_WORKTREE_BASE || "/workspace")
636
- : "",
637
- systemContext,
638
- mcpServersJson,
639
- mcpUrl,
640
- mcpToken,
641
- scriptContent: resolved.type === "script" ? resolved.script : "",
642
- pipe: req.pipe,
643
- });
644
- // Create lifecycle stream — every session gets one. The spawner holds
645
- // a lifecycle fd; when it's closed, the session auto-stops.
646
- const lifecycleStream = streamRegistry.createStream(`lifecycle:${sessionId}`);
647
- const spawnerId = req.parentSessionId || "__server__";
648
- streamRegistry.subscribe(lifecycleStream.id, spawnerId, "rw", "detach", true);
649
- streamRegistry.subscribe(lifecycleStream.id, sessionId, "rw", "detach", false);
650
- // Set up IPC pipe stream (optional, on top of lifecycle stream)
651
- let pipeFd = 0;
652
- if (pipeMode && pipeMode !== "detach" && req.parentSessionId) {
653
- const ipcStream = streamRegistry.createStream(`pipe:${sessionId}`);
654
- const parentSub = streamRegistry.subscribe(ipcStream.id, req.parentSessionId, "rw", pipeMode === "sync" ? "sync" : "async", true);
655
- streamRegistry.subscribe(ipcStream.id, sessionId, "rw", "async", false);
656
- pipeFd = parentSub.fd;
657
- if (pipeMode === "async") {
658
- ensureAsyncDeliveryListener(req.parentSessionId); // parent receives child messages
659
- ensureAsyncDeliveryListener(sessionId); // child receives parent messages
660
- }
661
- }
662
- // Push fresh credentials before spawning (best-effort).
663
- // For local envs, skip file tokens — the PowerLine is on the same machine.
664
- await tokenPush.refreshTokensForTask(req.environmentId, runtime, env.adapterType === "local" ? { excludeFileTokens: true } : undefined);
665
- processEventStream(conn.client.spawn(powerlineReq), {
666
- sessionId,
667
- logPath,
668
- systemContext,
669
- prompt: req.prompt,
670
- });
671
- const row = sessionStore.getSession(sessionId);
672
- const proto = sessionRowToProto(row);
673
- proto.pipeFd = pipeFd;
674
- return proto;
675
- },
676
- async resumeAgent(req) {
677
- const row = reanimateAgent(req.sessionId);
678
- return sessionRowToProto(row);
679
- },
680
- async sendInput(req) {
681
- const session = sessionStore.getSession(req.sessionId);
682
- if (!session) {
683
- throw new ConnectError(`Session not found: ${req.sessionId}`, Code.NotFound);
684
- }
685
- if (TERMINAL_SESSION_STATUSES.has(session.status)) {
686
- throw new ConnectError(`Session ${req.sessionId} has ended (status: ${session.status})`, Code.FailedPrecondition);
687
- }
688
- const conn = adapterManager.getConnection(session.environmentId);
689
- if (!conn) {
690
- throw new ConnectError(`Environment ${session.environmentId} not connected`, Code.FailedPrecondition);
691
- }
692
- // Persist and publish user input event so subscribers see the text in the event stream
693
- const userInputEvent = create(grackle.SessionEventSchema, {
694
- sessionId: req.sessionId,
695
- type: grackle.EventType.USER_INPUT,
696
- timestamp: new Date().toISOString(),
697
- content: req.text,
698
- raw: "",
699
- });
700
- if (session.logPath) {
701
- logWriter.writeEvent(session.logPath, userInputEvent);
702
- }
703
- streamHub.publish(userInputEvent);
704
- await conn.client.sendInput(create(powerline.InputMessageSchema, {
705
- sessionId: req.sessionId,
706
- text: req.text,
707
- }));
708
- return create(grackle.EmptySchema, {});
709
- },
710
- async getUsage(req) {
711
- if (!req.id) {
712
- throw new ConnectError("id is required", Code.InvalidArgument);
713
- }
714
- switch (req.scope) {
715
- case "session": {
716
- const session = sessionStore.getSession(req.id);
717
- if (!session) {
718
- throw new ConnectError(`Session not found: ${req.id}`, Code.NotFound);
719
- }
720
- return create(grackle.UsageStatsSchema, {
721
- inputTokens: session.inputTokens,
722
- outputTokens: session.outputTokens,
723
- costUsd: session.costUsd,
724
- sessionCount: 1,
725
- });
726
- }
727
- case "task": {
728
- const usage = sessionStore.aggregateUsage({ taskId: req.id });
729
- return create(grackle.UsageStatsSchema, usage);
730
- }
731
- case "task_tree": {
732
- const descendants = taskStore.getDescendants(req.id);
733
- const taskIds = [req.id, ...descendants.map((d) => d.id)];
734
- const usage = sessionStore.aggregateUsage({ taskIds });
735
- return create(grackle.UsageStatsSchema, usage);
736
- }
737
- case "workspace": {
738
- const tasks = taskStore.listTasks(req.id);
739
- const taskIds = tasks.map((t) => t.id);
740
- const usage = taskIds.length > 0
741
- ? sessionStore.aggregateUsage({ taskIds })
742
- : { inputTokens: 0, outputTokens: 0, costUsd: 0, sessionCount: 0 };
743
- return create(grackle.UsageStatsSchema, usage);
744
- }
745
- case "environment": {
746
- const usage = sessionStore.aggregateUsage({ environmentId: req.id });
747
- return create(grackle.UsageStatsSchema, usage);
748
- }
749
- default:
750
- throw new ConnectError(`Invalid usage scope: ${req.scope}`, Code.InvalidArgument);
751
- }
752
- },
753
- async waitForPipe(req) {
754
- const sub = streamRegistry.getSubscription(req.sessionId, req.fd);
755
- if (!sub) {
756
- throw new ConnectError(`No subscription found for session ${req.sessionId} fd ${req.fd}`, Code.NotFound);
757
- }
758
- if (sub.deliveryMode !== "sync") {
759
- throw new ConnectError(`Subscription fd ${req.fd} is not a sync subscription (mode: ${sub.deliveryMode})`, Code.FailedPrecondition);
760
- }
761
- // Capture child session ID before blocking — the pipe stream may be
762
- // removed by a concurrent fd close while consumeSync is awaiting.
763
- const pipeStream = streamRegistry.getStream(sub.streamId);
764
- const childSessionId = pipeStream?.name.startsWith("pipe:")
765
- ? pipeStream.name.slice("pipe:".length)
766
- : undefined;
767
- // Use try/finally so the pipe stream (and lifecycle stream) are cleaned up
768
- // even if consumeSync rejects (e.g., the request is cancelled or times out)
769
- // to prevent unbounded memory growth. Lifecycle cleanup also orphans the child,
770
- // triggering auto-stop so it doesn't linger in waiting_input (#824).
771
- let msg;
772
- try {
773
- msg = await streamRegistry.consumeSync(sub.id);
774
- }
775
- finally {
776
- pipeDelivery.cleanupSyncPipeAndLifecycle(sub.streamId, childSessionId);
777
- }
778
- return create(grackle.WaitForPipeResponseSchema, {
779
- content: msg.content,
780
- senderSessionId: msg.senderId,
781
- });
782
- },
783
- async writeToFd(req) {
784
- const sub = streamRegistry.getSubscription(req.sessionId, req.fd);
785
- if (!sub) {
786
- throw new ConnectError(`No subscription found for session ${req.sessionId} fd ${req.fd}`, Code.NotFound);
787
- }
788
- if (sub.permission !== "w" && sub.permission !== "rw") {
789
- throw new ConnectError(`Subscription fd ${req.fd} does not have write permission (permission: ${sub.permission})`, Code.FailedPrecondition);
790
- }
791
- const stream = streamRegistry.getStream(sub.streamId);
792
- if (!stream) {
793
- throw new ConnectError("Stream no longer exists", Code.FailedPrecondition);
794
- }
795
- // Publish to stream — delivery is handled by async listeners registered
796
- // at spawn time via ensureAsyncDeliveryListener. This is the same path
797
- // used by publishChildCompletion for child→parent delivery.
798
- const msg = streamRegistry.publish(sub.streamId, req.sessionId, req.message);
799
- // Verify delivery to async subscribers — check if the published message
800
- // was marked as delivered for each async target. Sync and detach subscribers
801
- // are excluded (sync waits for consumeSync, detach buffers silently).
802
- for (const targetSub of stream.subscriptions.values()) {
803
- if (targetSub.sessionId === req.sessionId) {
804
- continue;
805
- }
806
- if (targetSub.deliveryMode === "async" && !msg.deliveredTo.has(targetSub.id)) {
807
- throw new ConnectError("Message delivery failed — target environment may be disconnected", Code.FailedPrecondition);
808
- }
809
- }
810
- return create(grackle.EmptySchema, {});
811
- },
812
- async closeFd(req) {
813
- const sub = streamRegistry.getSubscription(req.sessionId, req.fd);
814
- if (!sub) {
815
- throw new ConnectError(`No subscription found for session ${req.sessionId} fd ${req.fd}`, Code.NotFound);
816
- }
817
- if (streamRegistry.hasUndeliveredMessages(sub.id)) {
818
- throw new ConnectError(`Cannot close fd ${req.fd}: undelivered messages pending. Process or consume them first.`, Code.FailedPrecondition);
819
- }
820
- const streamId = sub.streamId;
821
- const stream = streamRegistry.getStream(streamId);
822
- // Collect child sessions (inherited subscriptions, not the caller's)
823
- const childSubs = [];
824
- if (stream) {
825
- for (const s of stream.subscriptions.values()) {
826
- if (s.sessionId !== req.sessionId) {
827
- childSubs.push({ sessionId: s.sessionId, subId: s.id });
828
- }
829
- }
830
- }
831
- // Unsubscribe the caller
832
- streamRegistry.unsubscribe(sub.id);
833
- // Also unsubscribe children — when their last subscription is removed,
834
- // the lifecycle manager's orphan callback auto-stops them.
835
- let stopped = false;
836
- for (const child of childSubs) {
837
- streamRegistry.unsubscribe(child.subId);
838
- // Check if the child was orphaned (auto-stopped)
839
- const childSession = sessionStore.getSession(child.sessionId);
840
- if (childSession?.status === SESSION_STATUS.STOPPED) {
841
- stopped = true;
842
- }
843
- }
844
- // Clean up async listeners for caller and any unsubscribed children
845
- pipeDelivery.cleanupAsyncListenerIfEmpty(req.sessionId);
846
- for (const child of childSubs) {
847
- pipeDelivery.cleanupAsyncListenerIfEmpty(child.sessionId);
848
- }
849
- return create(grackle.CloseFdResponseSchema, { stopped });
850
- },
851
- getSessionFds(req) {
852
- const subs = streamRegistry.getSubscriptionsForSession(req.id);
853
- const fds = subs.map((sub) => {
854
- const stream = streamRegistry.getStream(sub.streamId);
855
- let targetSessionId = "";
856
- if (stream) {
857
- for (const s of stream.subscriptions.values()) {
858
- if (s.sessionId !== req.id) {
859
- targetSessionId = s.sessionId;
860
- break;
861
- }
862
- }
863
- }
864
- return create(grackle.FdInfoSchema, {
865
- fd: sub.fd,
866
- streamName: stream?.name || "",
867
- permission: sub.permission,
868
- deliveryMode: sub.deliveryMode,
869
- owned: sub.createdBySpawn,
870
- targetSessionId,
871
- });
872
- });
873
- return create(grackle.SessionFdsSchema, { fds });
874
- },
875
- async killAgent(req) {
876
- const session = sessionStore.getSession(req.id);
877
- if (!session) {
878
- throw new ConnectError(`Session not found: ${req.id}`, Code.NotFound);
879
- }
880
- if (req.graceful) {
881
- // ── SIGTERM: deliver signal message, return immediately ──
882
- if (!TERMINAL_SESSION_STATUSES.has(session.status)) {
883
- const message = "[SIGTERM] You have been asked to stop gracefully. " +
884
- "Finish your current operation, save your work, close any open IPC fds " +
885
- "(ipc_close for each owned fd), then call task_complete (if applicable) and stop.";
886
- // Set sigtermSentAt BEFORE delivering so that if the session
887
- // completes instantly (race), the event-processor sees the flag.
888
- sessionStore.setSigtermSentAt(session.id);
889
- const delivered = await sendInputToSession(session.id, session.environmentId, message, "sigterm");
890
- if (delivered) {
891
- return create(grackle.EmptySchema, {});
892
- }
893
- // Delivery failed — clear the flag since SIGTERM wasn't actually sent
894
- sessionStore.clearSigtermSentAt(session.id);
895
- // If delivery failed (env disconnected), fall through to hard kill
896
- logger.warn({ sessionId: session.id }, "SIGTERM delivery failed, falling back to hard kill");
897
- }
898
- }
899
- // ── SIGKILL: terminate immediately ──
900
- // Set STOPPED + killed BEFORE closing the lifecycle FD so the orphan
901
- // callback sees the session is already terminal and skips. Without this,
902
- // the orphan callback would see IDLE → reason="completed", which is wrong
903
- // for an explicit kill.
904
- killSessionAndCleanup(session);
905
- return create(grackle.EmptySchema, {});
906
- },
907
- async listSessions(req) {
908
- const rows = sessionStore.listSessions(req.environmentId, req.status);
909
- return create(grackle.SessionListSchema, {
910
- sessions: rows.map(sessionRowToProto),
911
- });
912
- },
913
- async getSession(req) {
914
- const row = sessionStore.getSession(req.id);
915
- if (!row) {
916
- throw new ConnectError(`Session not found: ${req.id}`, Code.NotFound);
917
- }
918
- return sessionRowToProto(row);
919
- },
920
- async getSessionEvents(req) {
921
- const session = sessionStore.getSession(req.id);
922
- if (!session) {
923
- throw new ConnectError(`Session not found: ${req.id}`, Code.NotFound);
924
- }
925
- if (!session.logPath) {
926
- return create(grackle.SessionEventListSchema, {
927
- sessionId: req.id,
928
- events: [],
929
- });
930
- }
931
- const entries = logWriter.readLog(session.logPath);
932
- return create(grackle.SessionEventListSchema, {
933
- sessionId: req.id,
934
- events: entries.map((e) => create(grackle.SessionEventSchema, {
935
- sessionId: e.session_id,
936
- type: eventTypeToEnum(e.type),
937
- timestamp: e.timestamp,
938
- content: e.content,
939
- raw: e.raw || "",
940
- })),
941
- });
942
- },
943
- async getTaskSessions(req) {
944
- if (!req.id) {
945
- throw new ConnectError("task id is required", Code.InvalidArgument);
946
- }
947
- const rows = sessionStore.listSessionsForTask(req.id);
948
- return create(grackle.SessionListSchema, {
949
- sessions: rows.map(sessionRowToProto),
950
- });
951
- },
952
- async *streamSession(req) {
953
- const stream = streamHub.createStream(req.id);
954
- try {
955
- for await (const event of stream) {
956
- yield event;
957
- }
958
- }
959
- finally {
960
- stream.cancel();
961
- }
962
- },
963
- async *streamAll() {
964
- const stream = streamHub.createGlobalStream();
965
- try {
966
- for await (const event of stream) {
967
- yield event;
968
- }
969
- }
970
- finally {
971
- stream.cancel();
972
- }
973
- },
974
- async *streamEvents() {
975
- const stream = createEventStream();
976
- try {
977
- for await (const event of stream) {
978
- yield event;
979
- }
980
- }
981
- finally {
982
- stream.cancel();
983
- }
984
- },
985
- async setToken(req) {
986
- if (!req.name) {
987
- throw new ConnectError("name is required", Code.InvalidArgument);
988
- }
989
- if (!req.value) {
990
- throw new ConnectError("value is required", Code.InvalidArgument);
991
- }
992
- tokenStore.setToken({
993
- name: req.name,
994
- type: req.type,
995
- envVar: req.envVar,
996
- filePath: req.filePath,
997
- value: req.value,
998
- expiresAt: req.expiresAt,
999
- });
1000
- emit("token.changed", {});
1001
- await tokenPush.pushToAll();
1002
- return create(grackle.EmptySchema, {});
1003
- },
1004
- async listTokens() {
1005
- const items = tokenStore.listTokens();
1006
- return create(grackle.TokenListSchema, {
1007
- tokens: items.map((t) => create(grackle.TokenInfoSchema, {
1008
- name: t.name,
1009
- type: t.type,
1010
- envVar: t.envVar || "",
1011
- filePath: t.filePath || "",
1012
- expiresAt: t.expiresAt || "",
1013
- })),
1014
- });
1015
- },
1016
- async deleteToken(req) {
1017
- if (!req.name) {
1018
- throw new ConnectError("name is required", Code.InvalidArgument);
1019
- }
1020
- tokenStore.deleteToken(req.name);
1021
- emit("token.changed", {});
1022
- await tokenPush.pushToAll();
1023
- return create(grackle.EmptySchema, {});
1024
- },
1025
- // ─── Credential Providers ─────────────────────────────────
1026
- async getCredentialProviders() {
1027
- const config = credentialProviders.getCredentialProviders();
1028
- return create(grackle.CredentialProviderConfigSchema, {
1029
- claude: claudeProviderModeToEnum(config.claude),
1030
- github: providerToggleToEnum(config.github),
1031
- copilot: providerToggleToEnum(config.copilot),
1032
- codex: providerToggleToEnum(config.codex),
1033
- goose: providerToggleToEnum(config.goose),
1034
- });
1035
- },
1036
- async setCredentialProvider(req) {
1037
- if (!credentialProviders.VALID_PROVIDERS.includes(req.provider)) {
1038
- throw new ConnectError(`Invalid provider: ${req.provider}. Must be one of: ${credentialProviders.VALID_PROVIDERS.join(", ")}`, Code.InvalidArgument);
1039
- }
1040
- const allowed = req.provider === "claude"
1041
- ? credentialProviders.VALID_CLAUDE_VALUES
1042
- : credentialProviders.VALID_TOGGLE_VALUES;
1043
- if (!allowed.has(req.value)) {
1044
- throw new ConnectError(`Invalid value for ${req.provider}: ${req.value}. Must be one of: ${[...allowed].join(", ")}`, Code.InvalidArgument);
1045
- }
1046
- const current = credentialProviders.getCredentialProviders();
1047
- const updated = { ...current, [req.provider]: req.value };
1048
- credentialProviders.setCredentialProviders(updated);
1049
- emit("credential.providers_changed", updated);
1050
- return create(grackle.CredentialProviderConfigSchema, {
1051
- claude: claudeProviderModeToEnum(updated.claude),
1052
- github: providerToggleToEnum(updated.github),
1053
- copilot: providerToggleToEnum(updated.copilot),
1054
- codex: providerToggleToEnum(updated.codex),
1055
- goose: providerToggleToEnum(updated.goose),
1056
- });
1057
- },
1058
- // ─── Workspaces ──────────────────────────────────────────
1059
- async listWorkspaces(req) {
1060
- const rows = workspaceStore.listWorkspaces(req.environmentId || undefined);
1061
- return create(grackle.WorkspaceListSchema, {
1062
- workspaces: rows.map(workspaceRowToProto),
1063
- });
1064
- },
1065
- async createWorkspace(req) {
1066
- if (!req.name) {
1067
- throw new ConnectError("name is required", Code.InvalidArgument);
1068
- }
1069
- if (!req.environmentId) {
1070
- throw new ConnectError("environment_id is required", Code.InvalidArgument);
1071
- }
1072
- const env = envRegistry.getEnvironment(req.environmentId);
1073
- if (!env) {
1074
- throw new ConnectError(`Environment not found: ${req.environmentId}`, Code.NotFound);
1075
- }
1076
- let id = slugify(req.name) || uuid().slice(0, 8);
1077
- // If slug already exists (e.g. archived workspace), append a short suffix
1078
- if (workspaceStore.getWorkspace(id)) {
1079
- id = `${id}-${uuid().slice(0, 4)}`;
1080
- }
1081
- // useWorktrees defaults to true when not specified
1082
- const useWorktrees = req.useWorktrees ?? true;
1083
- workspaceStore.createWorkspace(id, req.name, req.description, req.repoUrl, req.environmentId, useWorktrees, req.workingDirectory ?? "", req.defaultPersonaId ?? "");
1084
- emit("workspace.created", { workspaceId: id });
1085
- const row = workspaceStore.getWorkspace(id);
1086
- return workspaceRowToProto(row);
1087
- },
1088
- async getWorkspace(req) {
1089
- const row = workspaceStore.getWorkspace(req.id);
1090
- if (!row)
1091
- throw new ConnectError(`Workspace not found: ${req.id}`, Code.NotFound);
1092
- return workspaceRowToProto(row);
1093
- },
1094
- async archiveWorkspace(req) {
1095
- workspaceStore.archiveWorkspace(req.id);
1096
- emit("workspace.archived", { workspaceId: req.id });
1097
- return create(grackle.EmptySchema, {});
1098
- },
1099
- async updateWorkspace(req) {
1100
- const existing = workspaceStore.getWorkspace(req.id);
1101
- if (!existing) {
1102
- throw new ConnectError(`Workspace not found: ${req.id}`, Code.NotFound);
1103
- }
1104
- if (req.name?.trim() === "") {
1105
- throw new ConnectError("Workspace name cannot be empty", Code.InvalidArgument);
1106
- }
1107
- if (req.repoUrl !== undefined && req.repoUrl !== "" && !/^https?:\/\//i.test(req.repoUrl)) {
1108
- throw new ConnectError("Repository URL must use http or https scheme", Code.InvalidArgument);
1109
- }
1110
- if (req.environmentId !== undefined) {
1111
- const env = envRegistry.getEnvironment(req.environmentId);
1112
- if (!env) {
1113
- throw new ConnectError(`Environment not found: ${req.environmentId}`, Code.NotFound);
1114
- }
1115
- }
1116
- const row = workspaceStore.updateWorkspace(req.id, {
1117
- name: req.name !== undefined ? req.name.trim() : undefined,
1118
- description: req.description,
1119
- repoUrl: req.repoUrl,
1120
- environmentId: req.environmentId,
1121
- useWorktrees: req.useWorktrees ?? undefined,
1122
- workingDirectory: req.workingDirectory,
1123
- defaultPersonaId: req.defaultPersonaId,
1124
- });
1125
- if (!row) {
1126
- throw new ConnectError(`Workspace not found after update: ${req.id}`, Code.NotFound);
1127
- }
1128
- emit("workspace.updated", { workspaceId: req.id });
1129
- return workspaceRowToProto(row);
1130
- },
1131
- // ─── Tasks ───────────────────────────────────────────────
1132
- async listTasks(req) {
1133
- const rows = taskStore.listTasks(req.workspaceId || undefined, {
1134
- search: req.search || undefined,
1135
- status: req.status || undefined,
1136
- });
1137
- const childIdsMap = taskStore.buildChildIdsMap(rows);
1138
- // Batch-fetch sessions for all tasks and group by taskId
1139
- const taskIds = rows.map((r) => r.id);
1140
- const allSessions = sessionStore.listSessionsByTaskIds(taskIds);
1141
- const sessionsByTask = new Map();
1142
- for (const s of allSessions) {
1143
- const arr = sessionsByTask.get(s.taskId) ?? [];
1144
- arr.push(s);
1145
- sessionsByTask.set(s.taskId, arr);
1146
- }
1147
- return create(grackle.TaskListSchema, {
1148
- tasks: rows.map((r) => {
1149
- const taskSessions = sessionsByTask.get(r.id) ?? [];
1150
- const { status, latestSessionId } = computeTaskStatus(r.status, taskSessions);
1151
- return taskRowToProto(r, childIdsMap.get(r.id) ?? [], status, latestSessionId);
1152
- }),
1153
- });
1154
- },
1155
- async createTask(req) {
1156
- if (!req.title) {
1157
- throw new ConnectError("title is required", Code.InvalidArgument);
1158
- }
1159
- const workspaceId = req.workspaceId || undefined;
1160
- let workspace;
1161
- if (workspaceId) {
1162
- workspace = workspaceStore.getWorkspace(workspaceId);
1163
- if (!workspace)
1164
- throw new ConnectError(`Workspace not found: ${workspaceId}`, Code.NotFound);
1165
- }
1166
- // Validate parent task if specified
1167
- if (req.parentTaskId) {
1168
- const parent = taskStore.getTask(req.parentTaskId);
1169
- if (!parent)
1170
- throw new ConnectError(`Parent task not found: ${req.parentTaskId}`, Code.NotFound);
1171
- if (!parent.canDecompose) {
1172
- throw new ConnectError(`Parent task "${parent.title}" (${req.parentTaskId}) does not have decomposition rights`, Code.FailedPrecondition);
1173
- }
1174
- if (parent.depth + 1 > MAX_TASK_DEPTH) {
1175
- throw new ConnectError(`Task depth would exceed maximum of ${MAX_TASK_DEPTH}`, Code.FailedPrecondition);
1176
- }
1177
- }
1178
- const id = uuid().slice(0, 8);
1179
- taskStore.createTask(id, workspaceId, req.title, req.description, [...req.dependsOn], workspace ? slugify(workspace.name) : "", req.parentTaskId,
1180
- // Default to false (no decomposition rights) unless explicitly granted.
1181
- // Orchestrator/root processes that need fork() must opt in.
1182
- req.canDecompose ?? false, req.defaultPersonaId ?? "");
1183
- const row = taskStore.getTask(id);
1184
- emit("task.created", { taskId: id, workspaceId: req.workspaceId });
1185
- return taskRowToProto(row);
1186
- },
1187
- async getTask(req) {
1188
- const row = taskStore.getTask(req.id);
1189
- if (!row)
1190
- throw new ConnectError(`Task not found: ${req.id}`, Code.NotFound);
1191
- const taskSessions = sessionStore.listSessionsForTask(req.id);
1192
- const { status, latestSessionId } = computeTaskStatus(row.status, taskSessions);
1193
- return taskRowToProto(row, undefined, status, latestSessionId);
1194
- },
1195
- async updateTask(req) {
1196
- const existing = taskStore.getTask(req.id);
1197
- if (!existing)
1198
- throw new ConnectError(`Task not found: ${req.id}`, Code.NotFound);
1199
- let reqStatus = existing.status;
1200
- if (req.status !== grackle.TaskStatus.UNSPECIFIED) {
1201
- if (req.id === ROOT_TASK_ID) {
1202
- throw new ConnectError("Cannot change the status of the system task", Code.PermissionDenied);
1203
- }
1204
- const converted = taskStatusToString(req.status);
1205
- if (!converted) {
1206
- throw new ConnectError(`Unknown task status enum value: ${req.status}`, Code.InvalidArgument);
1207
- }
1208
- reqStatus = converted;
1209
- }
1210
- taskStore.updateTask(req.id, req.title !== "" ? req.title : existing.title, req.description !== "" ? req.description : existing.description, reqStatus, req.dependsOn.length > 0
1211
- ? [...req.dependsOn]
1212
- : safeParseJsonArray(existing.dependsOn), req.defaultPersonaId);
1213
- // Late-bind: associate an existing session with this task
1214
- if (req.sessionId !== "") {
1215
- const session = sessionStore.getSession(req.sessionId);
1216
- if (!session) {
1217
- throw new ConnectError(`Session not found: ${req.sessionId}`, Code.NotFound);
1218
- }
1219
- if (TERMINAL_SESSION_STATUSES.has(session.status)) {
1220
- throw new ConnectError(`Cannot bind terminal session ${req.sessionId} (status: ${session.status})`, Code.FailedPrecondition);
1221
- }
1222
- // Verify the processor exists before mutating DB state to avoid partial updates
1223
- if (!processorRegistry.get(req.sessionId)) {
1224
- throw new ConnectError(`No active event processor for session ${req.sessionId}`, Code.FailedPrecondition);
1225
- }
1226
- sessionStore.setSessionTask(req.sessionId, req.id);
1227
- processorRegistry.lateBind(req.sessionId, req.id, existing.workspaceId || undefined);
1228
- emit("task.started", { taskId: req.id, sessionId: req.sessionId, workspaceId: existing.workspaceId || "" });
1229
- }
1230
- emit("task.updated", { taskId: req.id, workspaceId: existing.workspaceId || "" });
1231
- const row = taskStore.getTask(req.id);
1232
- const taskSessions = sessionStore.listSessionsForTask(req.id);
1233
- const { status, latestSessionId } = computeTaskStatus(row.status, taskSessions);
1234
- return taskRowToProto(row, undefined, status, latestSessionId);
1235
- },
1236
- async startTask(req) {
1237
- const task = taskStore.getTask(req.taskId);
1238
- if (!task)
1239
- throw new ConnectError(`Task not found: ${req.taskId}`, Code.NotFound);
1240
- {
1241
- const taskSessions = sessionStore.listSessionsForTask(req.taskId);
1242
- const { status: effectiveStatus } = computeTaskStatus(task.status, taskSessions);
1243
- if (req.taskId === ROOT_TASK_ID) {
1244
- // Root task is always re-startable unless actively working
1245
- if (effectiveStatus === TASK_STATUS.WORKING) {
1246
- throw new ConnectError("System is already running", Code.FailedPrecondition);
1247
- }
1248
- }
1249
- else if (![TASK_STATUS.NOT_STARTED, TASK_STATUS.FAILED].includes(effectiveStatus)) {
1250
- throw new ConnectError(`Task ${req.taskId} cannot be started (status: ${effectiveStatus})`, Code.FailedPrecondition);
1251
- }
1252
- }
1253
- if (!taskStore.areDependenciesMet(req.taskId)) {
1254
- throw new ConnectError(`Task ${req.taskId} has unmet dependencies`, Code.FailedPrecondition);
1255
- }
1256
- const workspace = task.workspaceId ? workspaceStore.getWorkspace(task.workspaceId) : undefined;
1257
- if (task.workspaceId && !workspace) {
1258
- throw new ConnectError(`Workspace not found: ${task.workspaceId}`, Code.NotFound);
1259
- }
1260
- const environmentId = req.environmentId
1261
- || resolveAncestorEnvironmentId(task.parentTaskId)
1262
- || workspace?.environmentId
1263
- || "";
1264
- if (!environmentId) {
1265
- throw new ConnectError("No environment specified for task, ancestor, or workspace", Code.FailedPrecondition);
1266
- }
1267
- const conn = adapterManager.getConnection(environmentId);
1268
- if (!conn)
1269
- throw new ConnectError(`Environment ${environmentId} not connected`, Code.FailedPrecondition);
1270
- // Resolve persona via cascade (request → task → workspace → app default)
1271
- let resolved;
1272
- try {
1273
- resolved = resolvePersona(req.personaId, task.defaultPersonaId, workspace?.defaultPersonaId || "");
1274
- }
1275
- catch (err) {
1276
- throw new ConnectError(err.message, Code.FailedPrecondition);
1277
- }
1278
- // Validate pipe inputs before creating the session
1279
- validatePipeInputs(req.pipe, req.parentSessionId);
1280
- const taskPipeMode = req.pipe;
1281
- const env = envRegistry.getEnvironment(environmentId);
1282
- const sessionId = uuid();
1283
- const { runtime, model, maxTurns, systemPrompt, persona } = resolved;
1284
- const logPath = join(grackleHome, LOGS_DIR, sessionId);
1285
- // Root task always starts with the hardcoded greeting prompt; user messages
1286
- // are sent as follow-ups via sendInput. Other tasks use buildTaskPrompt.
1287
- const taskPrompt = task.id === ROOT_TASK_ID
1288
- ? ROOT_TASK_INITIAL_PROMPT
1289
- : buildTaskPrompt(task.title, task.description, req.notes);
1290
- const isOrchestrator = task.canDecompose && task.depth <= 1;
1291
- const orchestratorCtx = isOrchestrator
1292
- ? fetchOrchestratorContext(task.workspaceId || "")
1293
- : undefined;
1294
- const systemContext = new SystemPromptBuilder({
1295
- task: { title: task.title, description: task.description, notes: task.id === ROOT_TASK_ID ? "" : (req.notes || "") },
1296
- taskId: task.id,
1297
- canDecompose: task.canDecompose,
1298
- personaPrompt: systemPrompt,
1299
- taskDepth: task.depth,
1300
- workpad: task.workpad || undefined,
1301
- ...orchestratorCtx,
1302
- ...(orchestratorCtx && { triggerMode: "fresh" }),
1303
- }).build();
1304
- sessionStore.createSession(sessionId, environmentId, runtime, task.title, model, logPath, task.id, resolved.personaId, req.parentSessionId || "", // parentSessionId
1305
- taskPipeMode || "");
1306
- emit("task.started", { taskId: task.id, sessionId, workspaceId: task.workspaceId || "" });
1307
- // Re-push stored tokens + provider credentials (scoped to runtime) so they're fresh for this session.
1308
- // For local envs, skip file tokens — the PowerLine is on the same machine.
1309
- await tokenPush.refreshTokensForTask(environmentId, runtime, env?.adapterType === "local" ? { excludeFileTokens: true } : undefined);
1310
- const mcpServersJson = personaMcpServersToJson(persona);
1311
- const useWorktrees = workspace?.useWorktrees ?? false;
1312
- if (!useWorktrees) {
1313
- logger.warn({ taskId: task.id, workspaceId: task.workspaceId, branch: task.branch }, "Worktrees disabled for workspace — agent will work in main checkout. Concurrent tasks on the same environment may conflict.");
1314
- }
1315
- const taskMcpPort = parseInt(process.env.GRACKLE_MCP_PORT || String(DEFAULT_MCP_PORT), 10);
1316
- const taskMcpDialHost = toDialableHost(process.env.GRACKLE_HOST || "127.0.0.1");
1317
- const taskMcpUrl = `http://${taskMcpDialHost}:${taskMcpPort}/mcp`;
1318
- const taskMcpToken = createScopedToken({ sub: task.id, pid: task.workspaceId || "", per: resolved.personaId, sid: sessionId }, loadOrCreateApiKey(grackleHome));
1319
- const powerlineReq = create(powerline.SpawnRequestSchema, {
1320
- sessionId,
1321
- runtime,
1322
- prompt: taskPrompt,
1323
- model,
1324
- maxTurns,
1325
- branch: task.branch,
1326
- workingDirectory: task.branch
1327
- ? (workspace?.workingDirectory || process.env.GRACKLE_WORKTREE_BASE || "/workspace")
1328
- : "",
1329
- useWorktrees,
1330
- systemContext,
1331
- workspaceId: task.workspaceId ?? undefined,
1332
- taskId: task.id,
1333
- mcpServersJson,
1334
- mcpUrl: taskMcpUrl,
1335
- mcpToken: taskMcpToken,
1336
- scriptContent: resolved.type === "script" ? resolved.script : "",
1337
- pipe: req.pipe,
1338
- });
1339
- // Create lifecycle stream for the task session
1340
- const taskLifecycleStream = streamRegistry.createStream(`lifecycle:${sessionId}`);
1341
- const taskSpawnerId = req.parentSessionId || "__server__";
1342
- streamRegistry.subscribe(taskLifecycleStream.id, taskSpawnerId, "rw", "detach", true);
1343
- streamRegistry.subscribe(taskLifecycleStream.id, sessionId, "rw", "detach", false);
1344
- // Set up IPC pipe stream (optional)
1345
- let taskPipeFd = 0;
1346
- if (taskPipeMode && taskPipeMode !== "detach" && req.parentSessionId) {
1347
- const ipcStream = streamRegistry.createStream(`pipe:${sessionId}`);
1348
- const parentSub = streamRegistry.subscribe(ipcStream.id, req.parentSessionId, "rw", taskPipeMode === "sync" ? "sync" : "async", true);
1349
- streamRegistry.subscribe(ipcStream.id, sessionId, "rw", "async", false);
1350
- taskPipeFd = parentSub.fd;
1351
- if (taskPipeMode === "async") {
1352
- ensureAsyncDeliveryListener(req.parentSessionId); // parent receives child messages
1353
- ensureAsyncDeliveryListener(sessionId); // child receives parent messages
1354
- }
1355
- }
1356
- processEventStream(conn.client.spawn(powerlineReq), {
1357
- sessionId,
1358
- logPath,
1359
- workspaceId: task.workspaceId ?? undefined,
1360
- taskId: task.id,
1361
- systemContext,
1362
- prompt: taskPrompt,
1363
- });
1364
- const row = sessionStore.getSession(sessionId);
1365
- const taskProto = sessionRowToProto(row);
1366
- taskProto.pipeFd = taskPipeFd;
1367
- return taskProto;
1368
- },
1369
- async completeTask(req) {
1370
- if (req.id === ROOT_TASK_ID) {
1371
- throw new ConnectError("Cannot complete the system task", Code.PermissionDenied);
1372
- }
1373
- const task = taskStore.getTask(req.id);
1374
- if (!task)
1375
- throw new ConnectError(`Task not found: ${req.id}`, Code.NotFound);
1376
- taskStore.markTaskComplete(task.id, TASK_STATUS.COMPLETE);
1377
- // Transfer ALL pipe fds from this task's sessions to the grandparent BEFORE
1378
- // closing sessions — once sessions are cleaned up, their subscriptions are gone.
1379
- // Always transfer regardless of orphaned tasks: ipc_spawn creates child sessions
1380
- // (not tasks), so pipe subs exist even when getOrphanedTasks returns empty.
1381
- const grandparentId = task.parentTaskId || ROOT_TASK_ID;
1382
- transferAllPipeSubscriptions(task.id, grandparentId);
1383
- // Close lifecycle FDs for any active sessions — cascades to STOPPED via orphan callback
1384
- const activeSessions = sessionStore.getActiveSessionsForTask(req.id);
1385
- for (const activeSession of activeSessions) {
1386
- cleanupLifecycleStream(activeSession.id);
1387
- const subs = streamRegistry.getSubscriptionsForSession(activeSession.id);
1388
- for (const sub of subs) {
1389
- streamRegistry.unsubscribe(sub.id);
1390
- }
1391
- }
1392
- // Check for newly unblocked tasks
1393
- if (task.workspaceId) {
1394
- const unblocked = taskStore.checkAndUnblock(task.workspaceId);
1395
- for (const t of unblocked) {
1396
- streamHub.publish(create(grackle.SessionEventSchema, {
1397
- sessionId: "",
1398
- type: grackle.EventType.SYSTEM,
1399
- timestamp: new Date().toISOString(),
1400
- content: JSON.stringify({
1401
- type: "task_unblocked",
1402
- taskId: t.id,
1403
- title: t.title,
1404
- }),
1405
- raw: "",
1406
- }));
1407
- }
1408
- }
1409
- emit("task.completed", { taskId: task.id, workspaceId: task.workspaceId || "" });
1410
- const row = taskStore.getTask(task.id);
1411
- const taskSessions = sessionStore.listSessionsForTask(task.id);
1412
- const { status, latestSessionId } = computeTaskStatus(row.status, taskSessions);
1413
- return taskRowToProto(row, undefined, status, latestSessionId);
1414
- },
1415
- async setWorkpad(req) {
1416
- const task = taskStore.getTask(req.taskId);
1417
- if (!task) {
1418
- throw new ConnectError(`Task not found: ${req.taskId}`, Code.NotFound);
1419
- }
1420
- // Validate workpad is a valid JSON object
1421
- try {
1422
- const parsed = JSON.parse(req.workpad);
1423
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1424
- throw new ConnectError("Workpad must be a JSON object", Code.InvalidArgument);
1425
- }
1426
- }
1427
- catch (err) {
1428
- if (err instanceof ConnectError) {
1429
- throw err;
1430
- }
1431
- throw new ConnectError("Workpad must be valid JSON", Code.InvalidArgument);
1432
- }
1433
- const MAX_WORKPAD_BYTES = 64 * 1024; // 64 KB
1434
- const workpadBytes = Buffer.byteLength(req.workpad, "utf8");
1435
- if (workpadBytes > MAX_WORKPAD_BYTES) {
1436
- throw new ConnectError(`Workpad exceeds maximum size of ${MAX_WORKPAD_BYTES} bytes`, Code.InvalidArgument);
1437
- }
1438
- taskStore.setWorkpad(req.taskId, req.workpad);
1439
- const row = taskStore.getTask(req.taskId);
1440
- const taskSessions = sessionStore.listSessionsForTask(req.taskId);
1441
- const { status, latestSessionId } = computeTaskStatus(row.status, taskSessions);
1442
- return taskRowToProto(row, undefined, status, latestSessionId);
1443
- },
1444
- async resumeTask(req) {
1445
- const task = taskStore.getTask(req.id);
1446
- if (!task)
1447
- throw new ConnectError(`Task not found: ${req.id}`, Code.NotFound);
1448
- const latestSession = sessionStore.getLatestSessionForTask(req.id);
1449
- if (!latestSession) {
1450
- throw new ConnectError(`Task ${req.id} has no sessions to resume`, Code.FailedPrecondition);
1451
- }
1452
- if (![SESSION_STATUS.STOPPED, SESSION_STATUS.SUSPENDED].includes(latestSession.status)) {
1453
- throw new ConnectError(`Latest session ${latestSession.id} is not resumable (status: ${latestSession.status})`, Code.FailedPrecondition);
1454
- }
1455
- if (!latestSession.runtimeSessionId) {
1456
- throw new ConnectError(`Latest session ${latestSession.id} has no runtime session ID — cannot resume`, Code.FailedPrecondition);
1457
- }
1458
- const conn = adapterManager.getConnection(latestSession.environmentId);
1459
- if (!conn) {
1460
- throw new ConnectError(`Environment ${latestSession.environmentId} not connected`, Code.FailedPrecondition);
1461
- }
1462
- const powerlineReq = create(powerline.ResumeRequestSchema, {
1463
- sessionId: latestSession.id,
1464
- runtimeSessionId: latestSession.runtimeSessionId,
1465
- runtime: latestSession.runtime,
1466
- });
1467
- const logPath = latestSession.logPath || join(grackleHome, LOGS_DIR, latestSession.id);
1468
- // Initiate the stream before mutating the DB. If resume() throws
1469
- // synchronously the DB is never touched, so no rollback is needed.
1470
- const resumeStream = conn.client.resume(powerlineReq);
1471
- // Reset session DB row to RUNNING (clears endedAt, error, etc.)
1472
- sessionStore.reanimateSession(latestSession.id);
1473
- // Re-create lifecycle stream if it was deleted during kill/stop
1474
- const resumeSpawnerId = latestSession.parentSessionId || "__server__";
1475
- ensureLifecycleStream(latestSession.id, resumeSpawnerId);
1476
- processEventStream(resumeStream, {
1477
- sessionId: latestSession.id,
1478
- logPath,
1479
- workspaceId: task.workspaceId ?? undefined,
1480
- taskId: task.id,
1481
- });
1482
- emit("task.started", { taskId: task.id, sessionId: latestSession.id, workspaceId: task.workspaceId || "" });
1483
- const row = sessionStore.getSession(latestSession.id);
1484
- return sessionRowToProto(row);
1485
- },
1486
- async stopTask(req) {
1487
- const task = taskStore.getTask(req.id);
1488
- if (!task) {
1489
- throw new ConnectError(`Task not found: ${req.id}`, Code.NotFound);
1490
- }
1491
- // Terminate all active sessions for this task using the fd-closure pattern
1492
- const activeSessions = sessionStore.getActiveSessionsForTask(req.id);
1493
- for (const activeSession of activeSessions) {
1494
- cleanupLifecycleStream(activeSession.id);
1495
- const subs = streamRegistry.getSubscriptionsForSession(activeSession.id);
1496
- for (const sub of subs) {
1497
- streamRegistry.unsubscribe(sub.id);
1498
- }
1499
- const current = sessionStore.getSession(activeSession.id);
1500
- if (current && !TERMINAL_SESSION_STATUSES.has(current.status)) {
1501
- sessionStore.updateSession(activeSession.id, SESSION_STATUS.STOPPED, undefined, undefined, END_REASON.INTERRUPTED);
1502
- streamHub.publish(create(grackle.SessionEventSchema, {
1503
- sessionId: activeSession.id,
1504
- type: grackle.EventType.STATUS,
1505
- timestamp: new Date().toISOString(),
1506
- content: END_REASON.INTERRUPTED,
1507
- raw: "",
1508
- }));
1509
- }
1510
- }
1511
- // Mark task complete
1512
- taskStore.markTaskComplete(req.id, TASK_STATUS.COMPLETE);
1513
- // Check for newly unblocked tasks
1514
- if (task.workspaceId) {
1515
- taskStore.checkAndUnblock(task.workspaceId);
1516
- }
1517
- emit("task.completed", { taskId: task.id, workspaceId: task.workspaceId || "" });
1518
- const updated = taskStore.getTask(req.id);
1519
- const taskSessions = sessionStore.listSessionsForTask(req.id);
1520
- const { status, latestSessionId } = computeTaskStatus(updated.status, taskSessions);
1521
- return taskRowToProto(updated, undefined, status, latestSessionId);
1522
- },
1523
- async deleteTask(req) {
1524
- if (req.id === ROOT_TASK_ID) {
1525
- throw new ConnectError("Cannot delete the system task", Code.PermissionDenied);
1526
- }
1527
- const task = taskStore.getTask(req.id);
1528
- if (!task) {
1529
- throw new ConnectError(`Task not found: ${req.id}`, Code.NotFound);
1530
- }
1531
- const children = taskStore.getChildren(req.id);
1532
- if (children.length > 0) {
1533
- throw new ConnectError("Cannot delete task with children. Delete children first.", Code.FailedPrecondition);
1534
- }
1535
- // Terminate all active sessions via lifecycle cleanup before deleting the task
1536
- const activeSessions = sessionStore.getActiveSessionsForTask(req.id);
1537
- for (const activeSession of activeSessions) {
1538
- cleanupLifecycleStream(activeSession.id);
1539
- const subs = streamRegistry.getSubscriptionsForSession(activeSession.id);
1540
- for (const sub of subs) {
1541
- streamRegistry.unsubscribe(sub.id);
1542
- }
1543
- }
1544
- const changes = taskStore.deleteTask(req.id);
1545
- if (changes === 0) {
1546
- logger.error({ taskId: req.id }, "deleteTask returned 0 changes despite task existing");
1547
- throw new ConnectError(`Failed to delete task ${req.id}: no rows affected`, Code.Internal);
1548
- }
1549
- emit("task.deleted", { taskId: req.id, workspaceId: task.workspaceId || "" });
1550
- return create(grackle.EmptySchema, {});
1551
- },
1552
- // ─── Personas ───────────────────────────────────────────────
1553
- async listPersonas() {
1554
- const rows = personaStore.listPersonas();
1555
- return create(grackle.PersonaListSchema, {
1556
- personas: rows.map(personaRowToProto),
1557
- });
1558
- },
1559
- async createPersona(req) {
1560
- if (!req.name)
1561
- throw new ConnectError("Persona name is required", Code.InvalidArgument);
1562
- const personaType = req.type || "agent";
1563
- if (personaType !== "agent" && personaType !== "script") {
1564
- throw new ConnectError(`Invalid persona type: "${personaType}". Must be "agent" or "script".`, Code.InvalidArgument);
1565
- }
1566
- if (personaType === "script") {
1567
- if (!req.script) {
1568
- throw new ConnectError("Script content is required for script personas", Code.InvalidArgument);
1569
- }
1570
- }
1571
- else {
1572
- if (!req.systemPrompt) {
1573
- throw new ConnectError("Persona system_prompt is required", Code.InvalidArgument);
1574
- }
1575
- }
1576
- // Enforce unique ID and unique name
1577
- let id = slugify(req.name) || uuid().slice(0, 8);
1578
- if (personaStore.getPersona(id)) {
1579
- id = `${id}-${uuid().slice(0, 4)}`;
1580
- }
1581
- if (personaStore.getPersonaByName(req.name)) {
1582
- throw new ConnectError(`Persona with name "${req.name}" already exists`, Code.AlreadyExists);
1583
- }
1584
- const toolConfigJson = JSON.stringify({
1585
- allowedTools: [...(req.toolConfig?.allowedTools || [])],
1586
- disallowedTools: [...(req.toolConfig?.disallowedTools || [])],
1587
- });
1588
- const mcpServersJson = JSON.stringify(req.mcpServers.map((s) => ({
1589
- name: s.name,
1590
- command: s.command,
1591
- args: [...s.args],
1592
- tools: [...s.tools],
1593
- })));
1594
- // Validate allowed MCP tools against the known tool registry
1595
- const allowedMcpTools = Array.isArray(req.allowedMcpTools) ? [...req.allowedMcpTools] : [];
1596
- if (allowedMcpTools.length > 0) {
1597
- const invalid = allowedMcpTools.filter((t) => !ALL_MCP_TOOL_NAMES.has(t));
1598
- if (invalid.length > 0) {
1599
- throw new ConnectError(`Invalid MCP tool name(s): ${invalid.join(", ")}`, Code.InvalidArgument);
1600
- }
1601
- }
1602
- const allowedMcpToolsJson = JSON.stringify(allowedMcpTools);
1603
- personaStore.createPersona(id, req.name, req.description, req.systemPrompt, toolConfigJson, req.runtime, req.model, req.maxTurns, mcpServersJson, personaType, req.script, allowedMcpToolsJson);
1604
- emit("persona.created", { personaId: id });
1605
- const row = personaStore.getPersona(id);
1606
- return personaRowToProto(row);
1607
- },
1608
- async getPersona(req) {
1609
- const row = personaStore.getPersona(req.id);
1610
- if (!row)
1611
- throw new ConnectError(`Persona not found: ${req.id}`, Code.NotFound);
1612
- return personaRowToProto(row);
1613
- },
1614
- async updatePersona(req) {
1615
- const existing = personaStore.getPersona(req.id);
1616
- if (!existing)
1617
- throw new ConnectError(`Persona not found: ${req.id}`, Code.NotFound);
1618
- // Only update toolConfig/mcpServers if the request provides non-empty values;
1619
- // otherwise keep the existing stored value.
1620
- const hasNewToolConfig = !!req.toolConfig &&
1621
- (req.toolConfig.allowedTools.length > 0 ||
1622
- req.toolConfig.disallowedTools.length > 0);
1623
- const toolConfigJson = hasNewToolConfig
1624
- ? JSON.stringify({
1625
- allowedTools: [...(req.toolConfig?.allowedTools || [])],
1626
- disallowedTools: [...(req.toolConfig?.disallowedTools || [])],
1627
- })
1628
- : existing.toolConfig;
1629
- const hasNewMcpServers = Array.isArray(req.mcpServers) && req.mcpServers.length > 0;
1630
- const mcpServersJson = hasNewMcpServers
1631
- ? JSON.stringify(req.mcpServers.map((s) => ({
1632
- name: s.name,
1633
- command: s.command,
1634
- args: [...s.args],
1635
- tools: [...s.tools],
1636
- })))
1637
- : existing.mcpServers;
1638
- // Treat empty string / 0 as "not set" and keep existing value
1639
- const name = req.name || existing.name;
1640
- if (name !== existing.name && personaStore.getPersonaByName(name)) {
1641
- throw new ConnectError(`Persona with name "${name}" already exists`, Code.AlreadyExists);
1642
- }
1643
- const description = req.description || existing.description;
1644
- const systemPrompt = req.systemPrompt || existing.systemPrompt;
1645
- const runtime = req.runtime || existing.runtime;
1646
- const model = req.model || existing.model;
1647
- const maxTurns = req.maxTurns === 0 ? existing.maxTurns : req.maxTurns;
1648
- // Empty string means "keep existing", non-empty means "set to this value"
1649
- const updatedType = req.type || existing.type;
1650
- const updatedScript = req.script || existing.script;
1651
- // Preserve existing allowedMcpTools unless the request provides a non-empty list.
1652
- // Proto3 repeated fields default to [], so we treat [] as "not provided" (keep existing).
1653
- // To clear overrides (revert to default), callers should use a sentinel like ["__clear__"]
1654
- // or the web UI handles it explicitly via the McpToolSelector onChange.
1655
- const hasNewAllowedMcpTools = Array.isArray(req.allowedMcpTools) && req.allowedMcpTools.length > 0;
1656
- let allowedMcpToolsJson;
1657
- if (hasNewAllowedMcpTools) {
1658
- // Check for the clear sentinel: a single-element array ["__clear__"] resets to default
1659
- if (req.allowedMcpTools.length === 1 && req.allowedMcpTools[0] === "__clear__") {
1660
- allowedMcpToolsJson = "[]";
1661
- }
1662
- else {
1663
- const invalid = req.allowedMcpTools.filter((t) => !ALL_MCP_TOOL_NAMES.has(t));
1664
- if (invalid.length > 0) {
1665
- throw new ConnectError(`Invalid MCP tool name(s): ${invalid.join(", ")}`, Code.InvalidArgument);
1666
- }
1667
- allowedMcpToolsJson = JSON.stringify([...req.allowedMcpTools]);
1668
- }
1669
- }
1670
- else {
1671
- allowedMcpToolsJson = existing.allowedMcpTools;
1672
- }
1673
- personaStore.updatePersona(req.id, name, description, systemPrompt, toolConfigJson, runtime, model, maxTurns, mcpServersJson, updatedType, updatedScript, allowedMcpToolsJson);
1674
- emit("persona.updated", { personaId: req.id });
1675
- const row = personaStore.getPersona(req.id);
1676
- return personaRowToProto(row);
1677
- },
1678
- async deletePersona(req) {
1679
- personaStore.deletePersona(req.id);
1680
- emit("persona.deleted", { personaId: req.id });
1681
- return create(grackle.EmptySchema, {});
1682
- },
1683
- // ─── Schedules ────────────────────────────────────────────
1684
- async createSchedule(req) {
1685
- const title = req.title.trim();
1686
- const expr = req.scheduleExpression.trim();
1687
- const personaId = req.personaId.trim();
1688
- if (!title) {
1689
- throw new ConnectError("title is required", Code.InvalidArgument);
1690
- }
1691
- if (!expr) {
1692
- throw new ConnectError("schedule_expression is required", Code.InvalidArgument);
1693
- }
1694
- if (!personaId) {
1695
- throw new ConnectError("persona_id is required", Code.InvalidArgument);
1696
- }
1697
- // Validate persona exists
1698
- const persona = personaStore.getPersona(personaId);
1699
- if (!persona) {
1700
- throw new ConnectError(`Persona not found: ${personaId}`, Code.NotFound);
1701
- }
1702
- // Validate expression
1703
- try {
1704
- validateExpression(expr);
1705
- }
1706
- catch (err) {
1707
- throw new ConnectError(err instanceof Error ? err.message : "Invalid schedule expression", Code.InvalidArgument);
1708
- }
1709
- const id = uuid();
1710
- const nextRunAt = computeNextRunAt(expr);
1711
- scheduleStore.createSchedule(id, title, req.description, expr, personaId, req.environmentId, req.workspaceId, req.parentTaskId, nextRunAt);
1712
- emit("schedule.created", { scheduleId: id });
1713
- const row = scheduleStore.getSchedule(id);
1714
- return scheduleRowToProto(row);
1715
- },
1716
- async listSchedules(req) {
1717
- const rows = scheduleStore.listSchedules(req.workspaceId || undefined);
1718
- return create(grackle.ScheduleListSchema, {
1719
- schedules: rows.map(scheduleRowToProto),
1720
- });
1721
- },
1722
- async getSchedule(req) {
1723
- const row = scheduleStore.getSchedule(req.id);
1724
- if (!row) {
1725
- throw new ConnectError(`Schedule not found: ${req.id}`, Code.NotFound);
1726
- }
1727
- return scheduleRowToProto(row);
1728
- },
1729
- async updateSchedule(req) {
1730
- const existing = scheduleStore.getSchedule(req.id);
1731
- if (!existing) {
1732
- throw new ConnectError(`Schedule not found: ${req.id}`, Code.NotFound);
1733
- }
1734
- const update = {};
1735
- if (req.title !== undefined && req.title.trim() !== "") {
1736
- update.title = req.title.trim();
1737
- }
1738
- if (req.description !== undefined) {
1739
- update.description = req.description;
1740
- }
1741
- if (req.personaId !== undefined && req.personaId.trim() !== "") {
1742
- const trimmedPersonaId = req.personaId.trim();
1743
- const persona = personaStore.getPersona(trimmedPersonaId);
1744
- if (!persona) {
1745
- throw new ConnectError(`Persona not found: ${trimmedPersonaId}`, Code.NotFound);
1746
- }
1747
- update.personaId = trimmedPersonaId;
1748
- }
1749
- if (req.environmentId !== undefined) {
1750
- update.environmentId = req.environmentId;
1751
- }
1752
- // Handle schedule expression change
1753
- let expressionChanged = false;
1754
- if (req.scheduleExpression !== undefined && req.scheduleExpression !== "") {
1755
- const expr = req.scheduleExpression.trim();
1756
- try {
1757
- validateExpression(expr);
1758
- }
1759
- catch (err) {
1760
- throw new ConnectError(err instanceof Error ? err.message : "Invalid schedule expression", Code.InvalidArgument);
1761
- }
1762
- update.scheduleExpression = expr;
1763
- expressionChanged = true;
1764
- }
1765
- // Handle enable/disable
1766
- if (req.enabled !== undefined) {
1767
- update.enabled = req.enabled;
1768
- if (req.enabled) {
1769
- const expr = update.scheduleExpression ?? existing.scheduleExpression;
1770
- update.nextRunAt = computeNextRunAt(expr);
1771
- }
1772
- else {
1773
- update.nextRunAt = null;
1774
- }
1775
- }
1776
- else if (expressionChanged) {
1777
- // Recompute nextRunAt when expression changes (if currently enabled)
1778
- if (existing.enabled) {
1779
- update.nextRunAt = computeNextRunAt(update.scheduleExpression);
1780
- }
1781
- }
1782
- scheduleStore.updateSchedule(req.id, update);
1783
- emit("schedule.updated", { scheduleId: req.id });
1784
- const row = scheduleStore.getSchedule(req.id);
1785
- return scheduleRowToProto(row);
1786
- },
1787
- async deleteSchedule(req) {
1788
- scheduleStore.deleteSchedule(req.id);
1789
- emit("schedule.deleted", { scheduleId: req.id });
1790
- return create(grackle.EmptySchema, {});
1791
- },
1792
- // ─── Settings ─────────────────────────────────────────────
1793
- async getSetting(req) {
1794
- if (!isAllowedSettingKey(req.key)) {
1795
- throw new ConnectError(`Setting key not allowed: ${req.key}`, Code.InvalidArgument);
1796
- }
1797
- const value = settingsStore.getSetting(req.key);
1798
- return create(grackle.SettingResponseSchema, {
1799
- key: req.key,
1800
- value: value ?? "",
1801
- });
1802
- },
1803
- async setSetting(req) {
1804
- if (!isAllowedSettingKey(req.key)) {
1805
- throw new ConnectError(`Setting key not allowed: ${req.key}`, Code.InvalidArgument);
1806
- }
1807
- // Validate persona exists and has required fields when setting default_persona_id
1808
- if (req.key === "default_persona_id" && req.value) {
1809
- const persona = personaStore.getPersona(req.value);
1810
- if (!persona) {
1811
- throw new ConnectError(`Persona not found: ${req.value}`, Code.NotFound);
1812
- }
1813
- if (!persona.runtime || !persona.model) {
1814
- throw new ConnectError(`Persona "${persona.name}" must have runtime and model configured`, Code.FailedPrecondition);
1815
- }
1816
- }
1817
- settingsStore.setSetting(req.key, req.value);
1818
- emit("setting.changed", { key: req.key, value: req.value });
1819
- return create(grackle.SettingResponseSchema, {
1820
- key: req.key,
1821
- value: req.value,
1822
- });
1823
- },
1824
- // ─── Findings ────────────────────────────────────────────
1825
- async postFinding(req) {
1826
- if (!req.title) {
1827
- throw new ConnectError("title is required", Code.InvalidArgument);
1828
- }
1829
- const id = uuid().slice(0, 8);
1830
- findingStore.postFinding(id, req.workspaceId, req.taskId, req.sessionId, req.category, req.title, req.content, [...req.tags]);
1831
- emit("finding.posted", { workspaceId: req.workspaceId, findingId: id });
1832
- const rows = findingStore.queryFindings(req.workspaceId);
1833
- const row = rows.find((r) => r.id === id);
1834
- return findingRowToProto(row);
1835
- },
1836
- async queryFindings(req) {
1837
- const rows = findingStore.queryFindings(req.workspaceId, req.categories.length > 0 ? [...req.categories] : undefined, req.tags.length > 0 ? [...req.tags] : undefined, req.limit || undefined);
1838
- return create(grackle.FindingListSchema, {
1839
- findings: rows.map(findingRowToProto),
1840
- });
1841
- },
1842
- // ─── Codespaces ────────────────────────────────────────────
1843
- async listCodespaces() {
1844
- try {
1845
- const result = await exec("gh", [
1846
- "codespace",
1847
- "list",
1848
- "--json",
1849
- "name,repository,state,gitStatus",
1850
- "--limit",
1851
- String(GH_CODESPACE_LIST_LIMIT),
1852
- ], { timeout: GH_CODESPACE_LIST_TIMEOUT_MS });
1853
- const entries = JSON.parse(result.stdout || "[]");
1854
- return create(grackle.CodespaceListSchema, {
1855
- codespaces: entries.map((e) => create(grackle.CodespaceInfoSchema, {
1856
- name: String(e.name ?? ""),
1857
- repository: String(e.repository ?? ""),
1858
- state: String(e.state ?? ""),
1859
- gitStatus: String(e.gitStatus ?? ""),
1860
- })),
1861
- });
1862
- }
1863
- catch (err) {
1864
- logger.warn({ err }, "Failed to list codespaces");
1865
- return create(grackle.CodespaceListSchema, {
1866
- codespaces: [],
1867
- error: formatGhError(err, "list codespaces"),
1868
- });
1869
- }
1870
- },
1871
- async createCodespace(req) {
1872
- if (!req.repo.trim()) {
1873
- throw new ConnectError("repo is required", Code.InvalidArgument);
1874
- }
1875
- const trimmedRepo = req.repo.trim();
1876
- const createArgs = ["codespace", "create", "--repo", trimmedRepo];
1877
- if (req.machine.trim()) {
1878
- createArgs.push("--machine", req.machine.trim());
1879
- }
1880
- try {
1881
- const result = await exec("gh", createArgs, {
1882
- timeout: GH_CODESPACE_CREATE_TIMEOUT_MS,
1883
- });
1884
- return create(grackle.CreateCodespaceResponseSchema, {
1885
- name: result.stdout.trim(),
1886
- repository: trimmedRepo,
1887
- });
1888
- }
1889
- catch (err) {
1890
- logger.error({ err, repo: trimmedRepo }, "Failed to create codespace");
1891
- throw new ConnectError(formatGhError(err, "create codespace"), Code.Internal);
1892
- }
1893
- },
1894
- async generatePairingCode() {
1895
- const code = generatePairingCode();
1896
- if (!code) {
1897
- throw new ConnectError("Maximum active pairing codes reached. Wait for existing codes to expire.", Code.ResourceExhausted);
1898
- }
1899
- const webPort = parseInt(process.env.GRACKLE_WEB_PORT || String(DEFAULT_WEB_PORT), 10);
1900
- const bindHost = process.env.GRACKLE_HOST || "127.0.0.1";
1901
- const WILDCARD_ADDRESSES = new Set(["0.0.0.0", "::", "0:0:0:0:0:0:0:0"]);
1902
- const pairingHost = WILDCARD_ADDRESSES.has(bindHost)
1903
- ? (detectLanIp() || "localhost")
1904
- : (bindHost === "127.0.0.1" || bindHost === "::1" ? "localhost" : bindHost);
1905
- const url = `http://${pairingHost}:${webPort}/pair?code=${code}`;
1906
- return create(grackle.PairingCodeResponseSchema, { code, url });
1907
- },
1908
- // ── Knowledge Graph ────────────────────────────────────────
1909
- async searchKnowledge(req) {
1910
- const embedder = getKnowledgeEmbedder();
1911
- if (!embedder) {
1912
- throw new ConnectError("Knowledge graph not available", Code.Unavailable);
1913
- }
1914
- const results = await knowledgeSearch(req.query, embedder, {
1915
- limit: req.limit || 10,
1916
- workspaceId: req.workspaceId || undefined,
1917
- });
1918
- return create(grackle.SearchKnowledgeResponseSchema, {
1919
- results: results.map((r) => create(grackle.SearchKnowledgeResultSchema, {
1920
- score: r.score,
1921
- node: knowledgeNodeToProto(r.node),
1922
- edges: r.edges.map(knowledgeEdgeToProto),
1923
- })),
1924
- });
1925
- },
1926
- async getKnowledgeNode(req) {
1927
- if (!isKnowledgeEnabled()) {
1928
- throw new ConnectError("Knowledge graph not available", Code.Unavailable);
1929
- }
1930
- const result = await getKnowledgeNodeById(req.id);
1931
- if (!result) {
1932
- throw new ConnectError(`Knowledge node not found: ${req.id}`, Code.NotFound);
1933
- }
1934
- return create(grackle.GetKnowledgeNodeResponseSchema, {
1935
- node: knowledgeNodeToProto(result.node),
1936
- edges: result.edges.map(knowledgeEdgeToProto),
1937
- });
1938
- },
1939
- async expandKnowledgeNode(req) {
1940
- if (!isKnowledgeEnabled()) {
1941
- throw new ConnectError("Knowledge graph not available", Code.Unavailable);
1942
- }
1943
- const result = await expandNode(req.id, {
1944
- depth: req.depth || 1,
1945
- edgeTypes: req.edgeTypes.length > 0 ? req.edgeTypes : undefined,
1946
- });
1947
- return create(grackle.ExpandKnowledgeNodeResponseSchema, {
1948
- nodes: result.nodes.map(knowledgeNodeToProto),
1949
- edges: result.edges.map(knowledgeEdgeToProto),
1950
- });
1951
- },
1952
- async listRecentKnowledgeNodes(req) {
1953
- if (!isKnowledgeEnabled()) {
1954
- throw new ConnectError("Knowledge graph not available", Code.Unavailable);
1955
- }
1956
- const result = await listRecentNodes(req.limit || 20, req.workspaceId || undefined);
1957
- return create(grackle.ListRecentKnowledgeNodesResponseSchema, {
1958
- nodes: result.nodes.map(knowledgeNodeToProto),
1959
- edges: result.edges.map(knowledgeEdgeToProto),
1960
- });
1961
- },
1962
- async createKnowledgeNode(req) {
1963
- const embedder = getKnowledgeEmbedder();
1964
- if (!embedder) {
1965
- throw new ConnectError("Knowledge graph not available", Code.Unavailable);
1966
- }
1967
- const chunker = createPassThroughChunker();
1968
- const embedded = await ingest(req.content, chunker, embedder);
1969
- if (embedded.length === 0) {
1970
- throw new ConnectError("Content produced no embeddings", Code.InvalidArgument);
1971
- }
1972
- const id = await createNativeNode({
1973
- category: (req.category || "insight"),
1974
- title: req.title,
1975
- content: req.content,
1976
- tags: [...req.tags],
1977
- embedding: embedded[0].vector,
1978
- workspaceId: req.workspaceId || "",
1979
- });
1980
- return create(grackle.CreateKnowledgeNodeResponseSchema, { id });
1981
- },
1982
- // ── Version ──────────────────────────────────────────────
1983
- async getVersionStatus() {
1984
- const status = await checkVersionStatus();
1985
- return create(grackle.VersionStatusSchema, {
1986
- currentVersion: status.currentVersion,
1987
- latestVersion: status.latestVersion,
1988
- updateAvailable: status.updateAvailable,
1989
- isDocker: status.isDocker,
1990
- });
1991
- },
1992
- });
1993
- }
1994
- // ---------------------------------------------------------------------------
1995
- // Knowledge graph proto converters
1996
- // ---------------------------------------------------------------------------
1997
- /** Convert a KnowledgeNode to its proto representation. */
1998
- function knowledgeNodeToProto(node) {
1999
- return create(grackle.KnowledgeNodeProtoSchema, {
2000
- id: node.id,
2001
- kind: node.kind,
2002
- workspaceId: node.workspaceId,
2003
- createdAt: node.createdAt,
2004
- updatedAt: node.updatedAt,
2005
- sourceType: node.kind === "reference" ? node.sourceType : "",
2006
- sourceId: node.kind === "reference" ? node.sourceId : "",
2007
- label: node.kind === "reference" ? node.label : "",
2008
- category: node.kind === "native" ? node.category : "",
2009
- title: node.kind === "native" ? node.title : "",
2010
- content: node.kind === "native" ? node.content : "",
2011
- tags: node.kind === "native" ? node.tags : [],
2012
- });
2013
- }
2014
- /** Convert a KnowledgeEdge to its proto representation. */
2015
- function knowledgeEdgeToProto(edge) {
2016
- return create(grackle.KnowledgeEdgeProtoSchema, {
2017
- fromId: edge.fromId,
2018
- toId: edge.toId,
2019
- type: edge.type,
2020
- metadataJson: edge.metadata ? JSON.stringify(edge.metadata) : "",
2021
- createdAt: edge.createdAt,
21
+ ...environments,
22
+ ...sessions,
23
+ ...tasks,
24
+ ...workspaces,
25
+ ...personas,
26
+ ...schedules,
27
+ ...tokens,
28
+ ...findings,
29
+ ...codespaces,
30
+ ...knowledge,
31
+ ...settings,
2022
32
  });
2023
33
  }
2024
34
  //# sourceMappingURL=grpc-service.js.map