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