pi-webdesk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -0
- package/dist/apps/daemon/src/appearance-preferences.js +218 -0
- package/dist/apps/daemon/src/auth.js +88 -0
- package/dist/apps/daemon/src/bin.js +123 -0
- package/dist/apps/daemon/src/cli.js +48 -0
- package/dist/apps/daemon/src/event-hub.js +155 -0
- package/dist/apps/daemon/src/index.js +102 -0
- package/dist/apps/daemon/src/launcher-control.js +114 -0
- package/dist/apps/daemon/src/launcher.js +73 -0
- package/dist/apps/daemon/src/pi-auth.js +290 -0
- package/dist/apps/daemon/src/pi-resources.js +182 -0
- package/dist/apps/daemon/src/pi-runtime-factory.js +19 -0
- package/dist/apps/daemon/src/pi-sessions.js +265 -0
- package/dist/apps/daemon/src/runtime-process.js +241 -0
- package/dist/apps/daemon/src/secret.js +71 -0
- package/dist/apps/daemon/src/server.js +1662 -0
- package/dist/apps/daemon/src/session-projection.js +117 -0
- package/dist/apps/daemon/src/state-lock.js +31 -0
- package/dist/apps/daemon/src/static-web.js +53 -0
- package/dist/apps/daemon/src/task-archive.js +152 -0
- package/dist/apps/daemon/src/task-commit.js +503 -0
- package/dist/apps/daemon/src/task-merge.js +912 -0
- package/dist/apps/daemon/src/task-review.js +204 -0
- package/dist/apps/daemon/src/task-runtime.js +1124 -0
- package/dist/apps/daemon/src/task-validation.js +352 -0
- package/dist/apps/daemon/src/workspace-store.js +140 -0
- package/dist/apps/daemon/src/workspace.js +795 -0
- package/dist/extensions/webdesk.js +34 -0
- package/dist/packages/git/src/commit.js +675 -0
- package/dist/packages/git/src/errors.js +55 -0
- package/dist/packages/git/src/fingerprint.js +286 -0
- package/dist/packages/git/src/index.js +123 -0
- package/dist/packages/git/src/merge.js +1008 -0
- package/dist/packages/git/src/paths.js +58 -0
- package/dist/packages/git/src/repository.js +77 -0
- package/dist/packages/git/src/review.js +396 -0
- package/dist/packages/git/src/runner.js +110 -0
- package/dist/packages/git/src/validation.js +263 -0
- package/dist/packages/git/src/worktree.js +233 -0
- package/dist/packages/pi-bridge/extensions/pita-policy.js +117 -0
- package/dist/packages/pi-bridge/src/auth.js +80 -0
- package/dist/packages/pi-bridge/src/errors.js +19 -0
- package/dist/packages/pi-bridge/src/handshake.js +43 -0
- package/dist/packages/pi-bridge/src/index.js +76 -0
- package/dist/packages/pi-bridge/src/jsonl.js +105 -0
- package/dist/packages/pi-bridge/src/policy-approval.js +62 -0
- package/dist/packages/pi-bridge/src/resolve.js +59 -0
- package/dist/packages/pi-bridge/src/resources-child.mjs +23 -0
- package/dist/packages/pi-bridge/src/resources.js +481 -0
- package/dist/packages/pi-bridge/src/rpc/client.js +480 -0
- package/dist/packages/pi-bridge/src/rpc/runtime.js +496 -0
- package/dist/packages/pi-bridge/src/rpc/supervisor.mjs +129 -0
- package/dist/packages/pi-bridge/src/rpc/tool-events.js +78 -0
- package/dist/packages/pi-bridge/src/rpc/wire.js +263 -0
- package/dist/packages/pi-bridge/src/runtime.js +0 -0
- package/dist/packages/pi-bridge/src/sessions-child.mjs +38 -0
- package/dist/packages/pi-bridge/src/sessions.js +314 -0
- package/dist/packages/pi-bridge/src/tool-activity.js +56 -0
- package/dist/packages/protocol/src/index.js +1863 -0
- package/dist/web/assets/index-BOw_fhvO.css +2 -0
- package/dist/web/assets/index-oXs7yAAo.js +119 -0
- package/dist/web/index.html +14 -0
- package/package.json +69 -0
- package/scripts/prepare.mjs +7 -0
|
@@ -0,0 +1,1662 @@
|
|
|
1
|
+
// apps/daemon/src/server.ts
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import {
|
|
8
|
+
PROTOCOL_VERSION,
|
|
9
|
+
continuePiSessionRequestSchema,
|
|
10
|
+
continuePiSessionResultSchema,
|
|
11
|
+
createTaskRequestSchema,
|
|
12
|
+
executeTaskCommitRequestSchema,
|
|
13
|
+
executeTaskMergeRequestSchema,
|
|
14
|
+
piAuthErrorSchema,
|
|
15
|
+
piAuthOperationSchema,
|
|
16
|
+
piAuthSnapshotSchema,
|
|
17
|
+
piConfigurationErrorSchema,
|
|
18
|
+
piConfigurationSnapshotSchema,
|
|
19
|
+
piSessionCatalogErrorSchema,
|
|
20
|
+
piSessionCatalogSnapshotSchema,
|
|
21
|
+
pitaPreferencesErrorSchema,
|
|
22
|
+
pitaPreferencesSnapshotSchema,
|
|
23
|
+
promptTaskRequestSchema,
|
|
24
|
+
registerRepositoryRequestSchema,
|
|
25
|
+
repositoryMergeSyncResultSchema,
|
|
26
|
+
repositoryMergeSyncRequestSchema,
|
|
27
|
+
respondPiAuthRequestSchema,
|
|
28
|
+
resolveTaskApprovalRequestSchema,
|
|
29
|
+
runTaskValidationRequestSchema,
|
|
30
|
+
safeParseClientMessage,
|
|
31
|
+
selectTaskModelRequestSchema,
|
|
32
|
+
serverControlMessageSchema,
|
|
33
|
+
serviceSessionSchema,
|
|
34
|
+
startPiAuthRequestSchema,
|
|
35
|
+
steerTaskRequestSchema,
|
|
36
|
+
taskArchiveRequestSchema,
|
|
37
|
+
taskCommitErrorSchema,
|
|
38
|
+
taskCommitPreflightRequestSchema,
|
|
39
|
+
taskCommitPreflightSchema,
|
|
40
|
+
taskCommitResultSchema,
|
|
41
|
+
taskMergeErrorSchema,
|
|
42
|
+
taskMergePreflightRequestSchema,
|
|
43
|
+
taskMergePreflightSchema,
|
|
44
|
+
taskMergeResultSchema,
|
|
45
|
+
taskReviewErrorSchema,
|
|
46
|
+
taskReviewSchema,
|
|
47
|
+
taskRuntimeErrorSchema,
|
|
48
|
+
taskRuntimeStatusSchema,
|
|
49
|
+
taskSessionTreeSchema,
|
|
50
|
+
taskValidationErrorSchema,
|
|
51
|
+
taskValidationSchema,
|
|
52
|
+
updatePitaPreferencesRequestSchema,
|
|
53
|
+
workspaceErrorSchema
|
|
54
|
+
} from "../../../packages/protocol/src/index.js";
|
|
55
|
+
import { createPiAuthManager } from "../../../packages/pi-bridge/src/index.js";
|
|
56
|
+
import {
|
|
57
|
+
SESSION_COOKIE_NAME,
|
|
58
|
+
createSessionToken,
|
|
59
|
+
generateBootstrapToken,
|
|
60
|
+
readCookie,
|
|
61
|
+
tokensMatch,
|
|
62
|
+
verifySessionToken
|
|
63
|
+
} from "./auth.js";
|
|
64
|
+
import { loadOrCreateSessionSecret } from "./secret.js";
|
|
65
|
+
import {
|
|
66
|
+
PitaPreferencesOperationError,
|
|
67
|
+
createPitaPreferencesService
|
|
68
|
+
} from "./appearance-preferences.js";
|
|
69
|
+
import {
|
|
70
|
+
PiAuthOperationError,
|
|
71
|
+
createPiAuthCoordinator
|
|
72
|
+
} from "./pi-auth.js";
|
|
73
|
+
import {
|
|
74
|
+
PiConfigurationOperationError,
|
|
75
|
+
createPiConfigurationService
|
|
76
|
+
} from "./pi-resources.js";
|
|
77
|
+
import {
|
|
78
|
+
PiSessionCatalogOperationError,
|
|
79
|
+
createPiSessionCatalogService
|
|
80
|
+
} from "./pi-sessions.js";
|
|
81
|
+
import { createTaskEventHub } from "./event-hub.js";
|
|
82
|
+
import { createPiTaskRuntimeFactory } from "./pi-runtime-factory.js";
|
|
83
|
+
import { acquireDaemonStateLock } from "./state-lock.js";
|
|
84
|
+
import {
|
|
85
|
+
RuntimeProcessRecoveryError,
|
|
86
|
+
captureRuntimeProcess,
|
|
87
|
+
recoverRuntimeProcess
|
|
88
|
+
} from "./runtime-process.js";
|
|
89
|
+
import {
|
|
90
|
+
TaskReviewOperationError,
|
|
91
|
+
createTaskReviewService
|
|
92
|
+
} from "./task-review.js";
|
|
93
|
+
import {
|
|
94
|
+
TaskValidationOperationError,
|
|
95
|
+
createTaskValidationService
|
|
96
|
+
} from "./task-validation.js";
|
|
97
|
+
import {
|
|
98
|
+
TaskCommitOperationError,
|
|
99
|
+
createTaskCommitService
|
|
100
|
+
} from "./task-commit.js";
|
|
101
|
+
import {
|
|
102
|
+
TaskMergeOperationError,
|
|
103
|
+
createTaskMergeService
|
|
104
|
+
} from "./task-merge.js";
|
|
105
|
+
import { createTaskArchiveService } from "./task-archive.js";
|
|
106
|
+
import { createStaticWebHandler } from "./static-web.js";
|
|
107
|
+
import {
|
|
108
|
+
TaskRuntimeOperationError,
|
|
109
|
+
createTaskRuntimeCoordinator
|
|
110
|
+
} from "./task-runtime.js";
|
|
111
|
+
import {
|
|
112
|
+
WorkspaceOperationError,
|
|
113
|
+
createWorkspaceService
|
|
114
|
+
} from "./workspace.js";
|
|
115
|
+
var LOOPBACK_ADDRESS = "127.0.0.1";
|
|
116
|
+
var MAX_BODY_BYTES = 4096;
|
|
117
|
+
var MAX_PROMPT_BODY_BYTES = 128 * 1024;
|
|
118
|
+
var DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1e3;
|
|
119
|
+
var DEFAULT_BOOTSTRAP_TTL_MS = 15 * 60 * 1e3;
|
|
120
|
+
var LOOPBACK_ORIGIN_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost"]);
|
|
121
|
+
var DaemonConfigError = class extends Error {
|
|
122
|
+
name = "DaemonConfigError";
|
|
123
|
+
};
|
|
124
|
+
var bootstrapBodySchema = z.object({
|
|
125
|
+
token: z.string().min(1).max(256)
|
|
126
|
+
});
|
|
127
|
+
function createDaemonServer(options) {
|
|
128
|
+
const serveWeb = options.webDirectory === void 0 ? void 0 : createStaticWebHandler(options.webDirectory);
|
|
129
|
+
const port = options.port ?? 0;
|
|
130
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
131
|
+
throw new DaemonConfigError("Daemon port must be an integer from 0 through 65535");
|
|
132
|
+
}
|
|
133
|
+
const sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
134
|
+
if (!Number.isSafeInteger(sessionTtlMs) || sessionTtlMs < 1e3) {
|
|
135
|
+
throw new DaemonConfigError("Session TTL must be a safe integer of at least 1000ms");
|
|
136
|
+
}
|
|
137
|
+
const bootstrapTtlMs = options.bootstrapTtlMs ?? DEFAULT_BOOTSTRAP_TTL_MS;
|
|
138
|
+
if (!Number.isSafeInteger(bootstrapTtlMs) || bootstrapTtlMs < 1e3) {
|
|
139
|
+
throw new DaemonConfigError("Bootstrap TTL must be a safe integer of at least 1000ms");
|
|
140
|
+
}
|
|
141
|
+
let originUrl;
|
|
142
|
+
try {
|
|
143
|
+
originUrl = new URL(options.allowedOrigin);
|
|
144
|
+
} catch {
|
|
145
|
+
throw new DaemonConfigError("Allowed browser origin must be a valid loopback HTTP origin");
|
|
146
|
+
}
|
|
147
|
+
if (originUrl.protocol !== "http:" || originUrl.origin !== options.allowedOrigin || !LOOPBACK_ORIGIN_HOSTS.has(originUrl.hostname)) {
|
|
148
|
+
throw new DaemonConfigError(
|
|
149
|
+
"Allowed browser origin must be an exact http://localhost or http://127.0.0.1 origin"
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const configuredStateDir = path.resolve(options.stateDir);
|
|
153
|
+
const secret = loadOrCreateSessionSecret(configuredStateDir);
|
|
154
|
+
const canonicalStateDir = realpathSync(configuredStateDir);
|
|
155
|
+
const workspace = options.workspaceService ?? createWorkspaceService({ stateDir: configuredStateDir });
|
|
156
|
+
const piAuth = options.piAuthCoordinator ?? createPiAuthCoordinator({ auth: createPiAuthManager() });
|
|
157
|
+
const piConfiguration = options.piConfigurationService ?? createPiConfigurationService({
|
|
158
|
+
getTask: async (taskId) => {
|
|
159
|
+
const snapshot = await workspace.snapshot();
|
|
160
|
+
return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
|
|
161
|
+
},
|
|
162
|
+
verifyTaskWorktree: (taskId) => workspace.verifyTaskWorktree(taskId)
|
|
163
|
+
});
|
|
164
|
+
const piSessions = options.piSessionCatalogService ?? createPiSessionCatalogService({ workspace });
|
|
165
|
+
const pitaPreferences = options.pitaPreferencesService ?? createPitaPreferencesService({ stateDir: configuredStateDir });
|
|
166
|
+
let taskArchive;
|
|
167
|
+
const taskReview = options.taskReviewService ?? createTaskReviewService({
|
|
168
|
+
getTask: async (taskId) => {
|
|
169
|
+
const snapshot = await workspace.snapshot();
|
|
170
|
+
return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
|
|
171
|
+
},
|
|
172
|
+
getRepository: async (repositoryId) => {
|
|
173
|
+
const snapshot = await workspace.snapshot();
|
|
174
|
+
return snapshot.state.repositories.find((repository) => repository.id === repositoryId) ?? null;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
const taskValidation = options.taskValidationService ?? createTaskValidationService({
|
|
178
|
+
getTask: async (taskId) => {
|
|
179
|
+
const snapshot = await workspace.snapshot();
|
|
180
|
+
return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
|
|
181
|
+
},
|
|
182
|
+
getRepository: async (repositoryId) => {
|
|
183
|
+
const snapshot = await workspace.snapshot();
|
|
184
|
+
return snapshot.state.repositories.find((repository) => repository.id === repositoryId) ?? null;
|
|
185
|
+
},
|
|
186
|
+
recordValidation: (taskId, result) => workspace.recordTaskValidation(taskId, result),
|
|
187
|
+
// Late-bound reciprocal busy checks; taskCommit and taskMerge are
|
|
188
|
+
// declared below and only consulted once a run request arrives.
|
|
189
|
+
isCommitActive: (taskId) => taskCommit.isBusy(taskId),
|
|
190
|
+
isMergeActive: (taskId) => taskMerge.isBusy(taskId),
|
|
191
|
+
isArchiveActive: (taskId) => taskArchive.isBusy(taskId),
|
|
192
|
+
isAgentWorking: async (taskId) => (await runtimeCoordinator.status(taskId)).working
|
|
193
|
+
});
|
|
194
|
+
const taskCommit = options.taskCommitService ?? createTaskCommitService({
|
|
195
|
+
getTask: async (taskId) => {
|
|
196
|
+
const snapshot = await workspace.snapshot();
|
|
197
|
+
return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
|
|
198
|
+
},
|
|
199
|
+
getRepository: async (repositoryId) => {
|
|
200
|
+
const snapshot = await workspace.snapshot();
|
|
201
|
+
return snapshot.state.repositories.find((repository) => repository.id === repositoryId) ?? null;
|
|
202
|
+
},
|
|
203
|
+
isValidationRunning: (taskId) => taskValidation.isRunning(taskId),
|
|
204
|
+
isMergeActive: (taskId) => taskMerge.isBusy(taskId),
|
|
205
|
+
isArchiveActive: (taskId) => taskArchive.isBusy(taskId),
|
|
206
|
+
// The runtime coordinator is declared below; a task with no live
|
|
207
|
+
// runtime reports an idle, non-working status.
|
|
208
|
+
isAgentWorking: async (taskId) => (await runtimeCoordinator.status(taskId)).working
|
|
209
|
+
});
|
|
210
|
+
const taskMerge = options.taskMergeService ?? createTaskMergeService({
|
|
211
|
+
getTask: async (taskId) => {
|
|
212
|
+
const snapshot = await workspace.snapshot();
|
|
213
|
+
return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
|
|
214
|
+
},
|
|
215
|
+
getRepository: async (repositoryId) => {
|
|
216
|
+
const snapshot = await workspace.snapshot();
|
|
217
|
+
return snapshot.state.repositories.find((repository) => repository.id === repositoryId) ?? null;
|
|
218
|
+
},
|
|
219
|
+
isValidationRunning: (taskId) => taskValidation.isRunning(taskId),
|
|
220
|
+
isCommitActive: (taskId) => taskCommit.isBusy(taskId),
|
|
221
|
+
isArchiveActive: (taskId) => taskArchive.isBusy(taskId),
|
|
222
|
+
isAgentWorking: async (taskId) => (await runtimeCoordinator.status(taskId)).working,
|
|
223
|
+
recordPendingMerge: (repositoryId, record) => workspace.recordPendingMerge(repositoryId, record),
|
|
224
|
+
clearPendingMerge: (repositoryId, preflightId, resolvedHead) => workspace.clearPendingMerge(repositoryId, preflightId, resolvedHead)
|
|
225
|
+
});
|
|
226
|
+
const runtimeRecoveryIssues = /* @__PURE__ */ new Map();
|
|
227
|
+
async function reconcileReadyTaskRuntimeProcess(task) {
|
|
228
|
+
const owner = task.runtimeProcess;
|
|
229
|
+
if (owner === void 0) {
|
|
230
|
+
runtimeRecoveryIssues.delete(task.id);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
await recoverRuntimeProcess(owner, {
|
|
235
|
+
...options.runtimeProcessInspector === void 0 ? {} : { inspector: options.runtimeProcessInspector },
|
|
236
|
+
...options.runtimeProcessRecoveryGraceMs === void 0 ? {} : { graceMs: options.runtimeProcessRecoveryGraceMs }
|
|
237
|
+
});
|
|
238
|
+
} catch (error) {
|
|
239
|
+
const recoveryError = error instanceof RuntimeProcessRecoveryError ? error : new RuntimeProcessRecoveryError(
|
|
240
|
+
`Runtime process ${owner.pid} could not be inspected safely`,
|
|
241
|
+
{ cause: error }
|
|
242
|
+
);
|
|
243
|
+
runtimeRecoveryIssues.set(task.id, recoveryError.message);
|
|
244
|
+
throw recoveryError;
|
|
245
|
+
}
|
|
246
|
+
await workspace.clearTaskRuntimeProcess(task.id, owner.runtimeGeneration, owner.pid);
|
|
247
|
+
runtimeRecoveryIssues.delete(task.id);
|
|
248
|
+
}
|
|
249
|
+
async function reconcileTaskRuntimeProcess(taskId) {
|
|
250
|
+
const current = (await workspace.snapshot()).state.tasks.find((task) => task.id === taskId);
|
|
251
|
+
if (current?.status === "ready") {
|
|
252
|
+
await reconcileReadyTaskRuntimeProcess(current);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const allowedOrigin = originUrl.origin;
|
|
256
|
+
const now = options.now ?? Date.now;
|
|
257
|
+
let bootstrapToken = generateBootstrapToken();
|
|
258
|
+
let pendingBootstrapToken = {
|
|
259
|
+
token: bootstrapToken,
|
|
260
|
+
expiresAt: now() + bootstrapTtlMs
|
|
261
|
+
};
|
|
262
|
+
const configuredPort = port > 0 ? port : null;
|
|
263
|
+
let boundPort = null;
|
|
264
|
+
let releaseStateLock = null;
|
|
265
|
+
let listenOperation = null;
|
|
266
|
+
let closeOperation = null;
|
|
267
|
+
let closed = false;
|
|
268
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: 4096 });
|
|
269
|
+
const sockets = /* @__PURE__ */ new Map();
|
|
270
|
+
const activeRequests = /* @__PURE__ */ new Set();
|
|
271
|
+
const eventHub = createTaskEventHub({
|
|
272
|
+
snapshot: (taskId) => runtimeCoordinator.snapshot(taskId)
|
|
273
|
+
});
|
|
274
|
+
function broadcastEvent(event) {
|
|
275
|
+
eventHub.publish(event);
|
|
276
|
+
}
|
|
277
|
+
const runtimeCoordinator = options.createRuntimeCoordinator !== void 0 ? options.createRuntimeCoordinator(broadcastEvent) : createTaskRuntimeCoordinator({
|
|
278
|
+
getTask: async (taskId) => {
|
|
279
|
+
const snapshot = await workspace.snapshot();
|
|
280
|
+
return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
|
|
281
|
+
},
|
|
282
|
+
recordTaskSession: (taskId, sessionFile) => workspace.recordTaskSession(taskId, sessionFile),
|
|
283
|
+
claimRuntimeGeneration: (taskId) => workspace.claimTaskRuntimeGeneration(taskId),
|
|
284
|
+
reconcileRuntimeProcess: reconcileTaskRuntimeProcess,
|
|
285
|
+
recordRuntimeProcess: async (taskId, generation, pid) => {
|
|
286
|
+
const identity = await captureRuntimeProcess(pid, options.runtimeProcessInspector);
|
|
287
|
+
await workspace.recordTaskRuntimeProcess(taskId, {
|
|
288
|
+
runtimeGeneration: generation,
|
|
289
|
+
...identity,
|
|
290
|
+
recordedAtMs: now()
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
clearRuntimeProcess: (taskId, generation, pid) => workspace.clearTaskRuntimeProcess(taskId, generation, pid),
|
|
294
|
+
createRuntime: createPiTaskRuntimeFactory({
|
|
295
|
+
...options.runtimeSupervisorIgnoreEofForTests === true ? { supervisorIgnoreEofForTests: true } : {}
|
|
296
|
+
}),
|
|
297
|
+
validateWorktree: (task) => workspace.verifyTaskWorktree(task.id),
|
|
298
|
+
isProviderAuthenticated: (providerId) => piAuth.isAuthenticated(providerId),
|
|
299
|
+
isTaskLifecycleBusy: (taskId) => taskValidation.isRunning(taskId) || taskCommit.isBusy(taskId) || taskMerge.isBusy(taskId) || taskArchive.isBusy(taskId),
|
|
300
|
+
isTaskArchiving: (taskId) => taskArchive.isBusy(taskId),
|
|
301
|
+
publish: broadcastEvent
|
|
302
|
+
});
|
|
303
|
+
taskArchive = options.taskArchiveService ?? createTaskArchiveService({
|
|
304
|
+
getTask: async (taskId) => {
|
|
305
|
+
const snapshot = await workspace.snapshot();
|
|
306
|
+
return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
|
|
307
|
+
},
|
|
308
|
+
getRepository: async (repositoryId) => {
|
|
309
|
+
const snapshot = await workspace.snapshot();
|
|
310
|
+
return snapshot.state.repositories.find((repository) => repository.id === repositoryId) ?? null;
|
|
311
|
+
},
|
|
312
|
+
archiveTask: (taskId) => workspace.archiveTask(taskId),
|
|
313
|
+
restoreTask: (taskId, archivedAtMs) => workspace.restoreTask(taskId, archivedAtMs),
|
|
314
|
+
disposeRuntime: (taskId) => runtimeCoordinator.stopForArchive(taskId),
|
|
315
|
+
isRuntimeMutating: (taskId) => runtimeCoordinator.isMutating(taskId),
|
|
316
|
+
isValidationRunning: (taskId) => taskValidation.isRunning(taskId),
|
|
317
|
+
isCommitActive: (taskId) => taskCommit.isBusy(taskId),
|
|
318
|
+
isMergeActive: (taskId) => taskMerge.isBusy(taskId),
|
|
319
|
+
isMergeRepositoryActive: (repositoryId) => taskMerge.isRepositoryBusy(repositoryId),
|
|
320
|
+
invalidateCommitPreflight: (taskId) => taskCommit.invalidate(taskId),
|
|
321
|
+
invalidateMergePreflight: (taskId) => taskMerge.invalidate(taskId)
|
|
322
|
+
});
|
|
323
|
+
function rotateBootstrapToken() {
|
|
324
|
+
bootstrapToken = generateBootstrapToken();
|
|
325
|
+
pendingBootstrapToken = {
|
|
326
|
+
token: bootstrapToken,
|
|
327
|
+
expiresAt: now() + bootstrapTtlMs
|
|
328
|
+
};
|
|
329
|
+
return bootstrapToken;
|
|
330
|
+
}
|
|
331
|
+
function hostAllowed(req) {
|
|
332
|
+
const expectedPort = boundPort ?? configuredPort;
|
|
333
|
+
if (expectedPort === null) {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
const host = req.headers.host;
|
|
337
|
+
return host === `${LOOPBACK_ADDRESS}:${expectedPort}` || host === `localhost:${expectedPort}`;
|
|
338
|
+
}
|
|
339
|
+
function sessionFromRequest(req) {
|
|
340
|
+
const cookie = readCookie(req.headers.cookie, SESSION_COOKIE_NAME);
|
|
341
|
+
if (cookie === null) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
return verifySessionToken(secret, cookie, now());
|
|
345
|
+
}
|
|
346
|
+
function sendJson(res, status, body) {
|
|
347
|
+
const payload = JSON.stringify(body);
|
|
348
|
+
res.writeHead(status, {
|
|
349
|
+
"content-type": "application/json; charset=utf-8",
|
|
350
|
+
"content-length": Buffer.byteLength(payload),
|
|
351
|
+
"cache-control": "no-store",
|
|
352
|
+
"x-content-type-options": "nosniff"
|
|
353
|
+
});
|
|
354
|
+
res.end(payload);
|
|
355
|
+
}
|
|
356
|
+
function sendError(res, status, error) {
|
|
357
|
+
sendJson(res, status, { error });
|
|
358
|
+
}
|
|
359
|
+
function sendWorkspaceError(res, status, code, message) {
|
|
360
|
+
sendJson(
|
|
361
|
+
res,
|
|
362
|
+
status,
|
|
363
|
+
workspaceErrorSchema.parse({
|
|
364
|
+
protocol: PROTOCOL_VERSION,
|
|
365
|
+
type: "workspace-error",
|
|
366
|
+
code,
|
|
367
|
+
message
|
|
368
|
+
})
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
function sendTaskRuntimeError(res, status, code, message) {
|
|
372
|
+
sendJson(
|
|
373
|
+
res,
|
|
374
|
+
status,
|
|
375
|
+
taskRuntimeErrorSchema.parse({
|
|
376
|
+
protocol: PROTOCOL_VERSION,
|
|
377
|
+
type: "task-runtime-error",
|
|
378
|
+
code,
|
|
379
|
+
message
|
|
380
|
+
})
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
function sendTaskReviewError(res, status, code, message) {
|
|
384
|
+
sendJson(
|
|
385
|
+
res,
|
|
386
|
+
status,
|
|
387
|
+
taskReviewErrorSchema.parse({
|
|
388
|
+
protocol: PROTOCOL_VERSION,
|
|
389
|
+
type: "task-review-error",
|
|
390
|
+
code,
|
|
391
|
+
message
|
|
392
|
+
})
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
function sendTaskValidationError(res, status, code, message) {
|
|
396
|
+
sendJson(
|
|
397
|
+
res,
|
|
398
|
+
status,
|
|
399
|
+
taskValidationErrorSchema.parse({
|
|
400
|
+
protocol: PROTOCOL_VERSION,
|
|
401
|
+
type: "task-validation-error",
|
|
402
|
+
code,
|
|
403
|
+
message
|
|
404
|
+
})
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
function sendTaskCommitError(res, status, code, message) {
|
|
408
|
+
sendJson(
|
|
409
|
+
res,
|
|
410
|
+
status,
|
|
411
|
+
taskCommitErrorSchema.parse({
|
|
412
|
+
protocol: PROTOCOL_VERSION,
|
|
413
|
+
type: "task-commit-error",
|
|
414
|
+
code,
|
|
415
|
+
message
|
|
416
|
+
})
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
function sendTaskMergeError(res, status, code, message, conflict) {
|
|
420
|
+
sendJson(
|
|
421
|
+
res,
|
|
422
|
+
status,
|
|
423
|
+
taskMergeErrorSchema.parse({
|
|
424
|
+
protocol: PROTOCOL_VERSION,
|
|
425
|
+
type: "task-merge-error",
|
|
426
|
+
code,
|
|
427
|
+
message,
|
|
428
|
+
...conflict?.conflictPaths === void 0 ? {} : {
|
|
429
|
+
conflictPaths: conflict.conflictPaths,
|
|
430
|
+
omittedConflictPaths: conflict.omittedConflictPaths ?? 0
|
|
431
|
+
}
|
|
432
|
+
})
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
function handleTaskMergeFailure(res, error) {
|
|
436
|
+
if (error instanceof TaskMergeOperationError) {
|
|
437
|
+
sendTaskMergeError(res, error.status, error.code, error.message, {
|
|
438
|
+
conflictPaths: error.conflictPaths,
|
|
439
|
+
omittedConflictPaths: error.omittedConflictPaths
|
|
440
|
+
});
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (error instanceof WorkspaceOperationError) {
|
|
444
|
+
sendTaskMergeError(res, error.status, "invalid-request", error.message);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
sendTaskMergeError(
|
|
448
|
+
res,
|
|
449
|
+
503,
|
|
450
|
+
"merge-failed",
|
|
451
|
+
"The task merge service is unavailable. Check the daemon output for details."
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
function sendPiAuthError(res, status, code, message) {
|
|
455
|
+
sendJson(
|
|
456
|
+
res,
|
|
457
|
+
status,
|
|
458
|
+
piAuthErrorSchema.parse({
|
|
459
|
+
protocol: PROTOCOL_VERSION,
|
|
460
|
+
type: "pi-auth-error",
|
|
461
|
+
code,
|
|
462
|
+
message
|
|
463
|
+
})
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
function sendPiConfigurationError(res, status, code, message) {
|
|
467
|
+
sendJson(
|
|
468
|
+
res,
|
|
469
|
+
status,
|
|
470
|
+
piConfigurationErrorSchema.parse({
|
|
471
|
+
protocol: PROTOCOL_VERSION,
|
|
472
|
+
type: "pi-configuration-error",
|
|
473
|
+
code,
|
|
474
|
+
message
|
|
475
|
+
})
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
function sendPiSessionCatalogError(res, status, code, message) {
|
|
479
|
+
sendJson(
|
|
480
|
+
res,
|
|
481
|
+
status,
|
|
482
|
+
piSessionCatalogErrorSchema.parse({
|
|
483
|
+
protocol: PROTOCOL_VERSION,
|
|
484
|
+
type: "pi-session-catalog-error",
|
|
485
|
+
code,
|
|
486
|
+
message
|
|
487
|
+
})
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
function sendPitaPreferencesError(res, status, code, message) {
|
|
491
|
+
sendJson(
|
|
492
|
+
res,
|
|
493
|
+
status,
|
|
494
|
+
pitaPreferencesErrorSchema.parse({
|
|
495
|
+
protocol: PROTOCOL_VERSION,
|
|
496
|
+
type: "pita-preferences-error",
|
|
497
|
+
code,
|
|
498
|
+
message
|
|
499
|
+
})
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
async function handlePiAuthOperation(res, operation, successStatus = 200) {
|
|
503
|
+
try {
|
|
504
|
+
sendJson(res, successStatus, await operation());
|
|
505
|
+
} catch (error) {
|
|
506
|
+
if (error instanceof PiAuthOperationError) {
|
|
507
|
+
sendPiAuthError(res, error.status, error.code, error.message);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (error instanceof WorkspaceOperationError) {
|
|
511
|
+
sendPiAuthError(res, error.status, "invalid-request", error.message);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
sendPiAuthError(
|
|
515
|
+
res,
|
|
516
|
+
503,
|
|
517
|
+
"auth-unavailable",
|
|
518
|
+
"Pi authentication is unavailable. Check the daemon output for details."
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function readBoundedBody(req, maxBytes = MAX_BODY_BYTES) {
|
|
523
|
+
return new Promise((resolve, reject) => {
|
|
524
|
+
const chunks = [];
|
|
525
|
+
let size = 0;
|
|
526
|
+
let tooLarge = false;
|
|
527
|
+
let settled = false;
|
|
528
|
+
const settle = (body) => {
|
|
529
|
+
if (settled) return;
|
|
530
|
+
settled = true;
|
|
531
|
+
resolve(body);
|
|
532
|
+
};
|
|
533
|
+
req.on("data", (chunk) => {
|
|
534
|
+
if (tooLarge) return;
|
|
535
|
+
size += chunk.length;
|
|
536
|
+
if (size > maxBytes) {
|
|
537
|
+
tooLarge = true;
|
|
538
|
+
chunks.length = 0;
|
|
539
|
+
settle(null);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
chunks.push(chunk);
|
|
543
|
+
});
|
|
544
|
+
req.on("end", () => settle(tooLarge ? null : Buffer.concat(chunks)));
|
|
545
|
+
req.on("error", (error) => {
|
|
546
|
+
if (!settled) reject(error);
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
async function handleBootstrap(req, res) {
|
|
551
|
+
const body = await readBoundedBody(req);
|
|
552
|
+
if (body === null) {
|
|
553
|
+
sendError(res, 413, "body-too-large");
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
let parsed;
|
|
557
|
+
try {
|
|
558
|
+
parsed = bootstrapBodySchema.parse(JSON.parse(body.toString("utf8")));
|
|
559
|
+
} catch {
|
|
560
|
+
sendError(res, 400, "invalid-body");
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (pendingBootstrapToken === null || pendingBootstrapToken.expiresAt <= now() || !tokensMatch(pendingBootstrapToken.token, parsed.token)) {
|
|
564
|
+
sendError(res, 403, "invalid-bootstrap-token");
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
pendingBootstrapToken = null;
|
|
568
|
+
const session = createSessionToken(secret, {
|
|
569
|
+
nowMs: now(),
|
|
570
|
+
ttlMs: sessionTtlMs
|
|
571
|
+
});
|
|
572
|
+
setSessionCookie(res, session);
|
|
573
|
+
res.writeHead(204);
|
|
574
|
+
res.end();
|
|
575
|
+
}
|
|
576
|
+
async function readJsonBody(req, maxBytes = MAX_BODY_BYTES) {
|
|
577
|
+
const body = await readBoundedBody(req, maxBytes);
|
|
578
|
+
if (body === null) {
|
|
579
|
+
throw new WorkspaceOperationError("invalid-request", "The request body is too large.", 413);
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
return JSON.parse(body.toString("utf8"));
|
|
583
|
+
} catch {
|
|
584
|
+
throw new WorkspaceOperationError(
|
|
585
|
+
"invalid-request",
|
|
586
|
+
"The request body must be valid JSON.",
|
|
587
|
+
400
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
async function handleWorkspaceOperation(res, operation, successStatus = 200) {
|
|
592
|
+
try {
|
|
593
|
+
sendJson(res, successStatus, await operation());
|
|
594
|
+
} catch (error) {
|
|
595
|
+
if (error instanceof WorkspaceOperationError) {
|
|
596
|
+
sendWorkspaceError(res, error.status, error.code, error.message);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
sendWorkspaceError(
|
|
600
|
+
res,
|
|
601
|
+
503,
|
|
602
|
+
"workspace-unavailable",
|
|
603
|
+
"The local workspace is unavailable. Check the daemon output for details."
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function setSessionCookie(res, session) {
|
|
608
|
+
res.setHeader(
|
|
609
|
+
"set-cookie",
|
|
610
|
+
`${SESSION_COOKIE_NAME}=${session.token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(sessionTtlMs / 1e3)}`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
function handleSession(res) {
|
|
614
|
+
const session = createSessionToken(secret, {
|
|
615
|
+
nowMs: now(),
|
|
616
|
+
ttlMs: sessionTtlMs
|
|
617
|
+
});
|
|
618
|
+
setSessionCookie(res, session);
|
|
619
|
+
sendJson(
|
|
620
|
+
res,
|
|
621
|
+
200,
|
|
622
|
+
serviceSessionSchema.parse({
|
|
623
|
+
protocol: PROTOCOL_VERSION,
|
|
624
|
+
type: "service-session",
|
|
625
|
+
authenticated: true,
|
|
626
|
+
sessionExpiresAt: session.expiresAt
|
|
627
|
+
})
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
async function handleRequest(req, res) {
|
|
631
|
+
res.setHeader("vary", "origin");
|
|
632
|
+
if (!hostAllowed(req)) {
|
|
633
|
+
sendError(res, 403, "forbidden-host");
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const origin = req.headers.origin;
|
|
637
|
+
if (origin !== void 0 && origin !== allowedOrigin) {
|
|
638
|
+
sendError(res, 403, "forbidden-origin");
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (origin === allowedOrigin) {
|
|
642
|
+
res.setHeader("access-control-allow-origin", allowedOrigin);
|
|
643
|
+
res.setHeader("access-control-allow-credentials", "true");
|
|
644
|
+
}
|
|
645
|
+
if (serveWeb && await serveWeb(req, res)) return;
|
|
646
|
+
if (req.method === "OPTIONS") {
|
|
647
|
+
if (origin !== allowedOrigin) {
|
|
648
|
+
sendError(res, 403, "forbidden-origin");
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
res.writeHead(204, {
|
|
652
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
653
|
+
"access-control-allow-headers": "content-type",
|
|
654
|
+
"access-control-max-age": "600"
|
|
655
|
+
});
|
|
656
|
+
res.end();
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const requestUrl = new URL(req.url ?? "/", `http://${LOOPBACK_ADDRESS}`);
|
|
660
|
+
const pathname = requestUrl.pathname;
|
|
661
|
+
if (pathname === "/api/auth/bootstrap" && req.method === "POST") {
|
|
662
|
+
if (origin !== allowedOrigin) {
|
|
663
|
+
sendError(res, 403, "origin-required");
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
await handleBootstrap(req, res);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (pathname === "/api/session" && req.method === "GET") {
|
|
670
|
+
const claims = sessionFromRequest(req);
|
|
671
|
+
if (claims === null) {
|
|
672
|
+
sendError(res, 401, "unauthenticated");
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
handleSession(res);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (pathname === "/api/workspace" && req.method === "GET") {
|
|
679
|
+
if (sessionFromRequest(req) === null) {
|
|
680
|
+
sendError(res, 401, "unauthenticated");
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
await handleWorkspaceOperation(res, () => workspace.snapshot());
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (pathname === "/api/preferences") {
|
|
687
|
+
if (sessionFromRequest(req) === null) {
|
|
688
|
+
sendError(res, 401, "unauthenticated");
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
const rawRepositoryId = requestUrl.searchParams.get("repositoryId");
|
|
692
|
+
const repositoryId = rawRepositoryId === null || rawRepositoryId === "" ? null : z.uuid().safeParse(rawRepositoryId);
|
|
693
|
+
if (repositoryId !== null && !repositoryId.success) {
|
|
694
|
+
sendPitaPreferencesError(
|
|
695
|
+
res,
|
|
696
|
+
400,
|
|
697
|
+
"invalid-request",
|
|
698
|
+
"Choose a known repository before loading appearance preferences."
|
|
699
|
+
);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const selectedRepositoryId = repositoryId === null ? null : repositoryId.data;
|
|
703
|
+
try {
|
|
704
|
+
if (selectedRepositoryId !== null) {
|
|
705
|
+
const snapshot = await workspace.snapshot();
|
|
706
|
+
if (!snapshot.state.repositories.some((entry) => entry.id === selectedRepositoryId)) {
|
|
707
|
+
sendPitaPreferencesError(
|
|
708
|
+
res,
|
|
709
|
+
404,
|
|
710
|
+
"repository-not-found",
|
|
711
|
+
"The selected repository is no longer registered."
|
|
712
|
+
);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (req.method === "GET") {
|
|
717
|
+
sendJson(
|
|
718
|
+
res,
|
|
719
|
+
200,
|
|
720
|
+
pitaPreferencesSnapshotSchema.parse(
|
|
721
|
+
await pitaPreferences.snapshot(selectedRepositoryId)
|
|
722
|
+
)
|
|
723
|
+
);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (req.method === "POST") {
|
|
727
|
+
if (origin !== allowedOrigin) {
|
|
728
|
+
sendError(res, 403, "origin-required");
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const parsed = updatePitaPreferencesRequestSchema.safeParse(await readJsonBody(req));
|
|
732
|
+
if (!parsed.success || parsed.data.repositoryId !== selectedRepositoryId) {
|
|
733
|
+
sendPitaPreferencesError(
|
|
734
|
+
res,
|
|
735
|
+
400,
|
|
736
|
+
"invalid-request",
|
|
737
|
+
"The appearance request does not match the selected repository."
|
|
738
|
+
);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (parsed.data.scope === "repository" && selectedRepositoryId === null) {
|
|
742
|
+
sendPitaPreferencesError(
|
|
743
|
+
res,
|
|
744
|
+
400,
|
|
745
|
+
"invalid-request",
|
|
746
|
+
"Choose a repository before changing repository appearance."
|
|
747
|
+
);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
sendJson(
|
|
751
|
+
res,
|
|
752
|
+
200,
|
|
753
|
+
pitaPreferencesSnapshotSchema.parse(await pitaPreferences.update(parsed.data))
|
|
754
|
+
);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
sendError(res, 404, "not-found");
|
|
758
|
+
} catch (error) {
|
|
759
|
+
if (error instanceof PitaPreferencesOperationError) {
|
|
760
|
+
sendPitaPreferencesError(res, error.status, error.code, error.message);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
sendPitaPreferencesError(
|
|
764
|
+
res,
|
|
765
|
+
503,
|
|
766
|
+
"preferences-unavailable",
|
|
767
|
+
"Webdesk appearance preferences are unavailable. Check the daemon output for details."
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (pathname === "/api/pi/auth" && req.method === "GET") {
|
|
773
|
+
if (sessionFromRequest(req) === null) {
|
|
774
|
+
sendError(res, 401, "unauthenticated");
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
await handlePiAuthOperation(
|
|
778
|
+
res,
|
|
779
|
+
async () => piAuthSnapshotSchema.parse(await piAuth.snapshot())
|
|
780
|
+
);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (pathname === "/api/pi/sessions") {
|
|
784
|
+
if (req.method !== "GET") {
|
|
785
|
+
sendError(res, 404, "not-found");
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (sessionFromRequest(req) === null) {
|
|
789
|
+
sendError(res, 401, "unauthenticated");
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
sendJson(res, 200, piSessionCatalogSnapshotSchema.parse(await piSessions.refresh()));
|
|
794
|
+
} catch (error) {
|
|
795
|
+
if (error instanceof PiSessionCatalogOperationError) {
|
|
796
|
+
sendPiSessionCatalogError(res, error.status, error.code, error.message);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
sendPiSessionCatalogError(
|
|
800
|
+
res,
|
|
801
|
+
503,
|
|
802
|
+
"session-unavailable",
|
|
803
|
+
"Pi sessions could not be inspected. Check the daemon output for details."
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (pathname === "/api/pi/sessions/continue") {
|
|
809
|
+
if (req.method !== "POST") {
|
|
810
|
+
sendError(res, 404, "not-found");
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (origin !== allowedOrigin) {
|
|
814
|
+
sendError(res, 403, "origin-required");
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (sessionFromRequest(req) === null) {
|
|
818
|
+
sendError(res, 401, "unauthenticated");
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
const parsed = continuePiSessionRequestSchema.safeParse(await readJsonBody(req));
|
|
823
|
+
if (!parsed.success) {
|
|
824
|
+
throw new PiSessionCatalogOperationError(
|
|
825
|
+
"invalid-request",
|
|
826
|
+
"Choose an existing Pi session, repository, task title, and base revision.",
|
|
827
|
+
400
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
sendJson(
|
|
831
|
+
res,
|
|
832
|
+
201,
|
|
833
|
+
continuePiSessionResultSchema.parse(
|
|
834
|
+
await piSessions.continueInNewTask(parsed.data)
|
|
835
|
+
)
|
|
836
|
+
);
|
|
837
|
+
} catch (error) {
|
|
838
|
+
if (error instanceof PiSessionCatalogOperationError) {
|
|
839
|
+
sendPiSessionCatalogError(res, error.status, error.code, error.message);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (error instanceof WorkspaceOperationError) {
|
|
843
|
+
sendPiSessionCatalogError(
|
|
844
|
+
res,
|
|
845
|
+
error.status,
|
|
846
|
+
"continuation-unavailable",
|
|
847
|
+
error.message
|
|
848
|
+
);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
sendPiSessionCatalogError(
|
|
852
|
+
res,
|
|
853
|
+
503,
|
|
854
|
+
"continuation-unavailable",
|
|
855
|
+
"The isolated task could not be created from this Pi session. Check the daemon output for details."
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const piConfigurationRoute = /^\/api\/tasks\/([^/]+)\/pi-configuration$/.exec(pathname);
|
|
861
|
+
if (piConfigurationRoute !== null) {
|
|
862
|
+
if (req.method !== "GET") {
|
|
863
|
+
sendError(res, 404, "not-found");
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (sessionFromRequest(req) === null) {
|
|
867
|
+
sendError(res, 401, "unauthenticated");
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
const taskId = z.uuid().safeParse(piConfigurationRoute[1] ?? "");
|
|
871
|
+
if (!taskId.success) {
|
|
872
|
+
sendPiConfigurationError(res, 400, "invalid-request", "Choose a known task.");
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
try {
|
|
876
|
+
sendJson(
|
|
877
|
+
res,
|
|
878
|
+
200,
|
|
879
|
+
piConfigurationSnapshotSchema.parse(await piConfiguration.inspect(taskId.data))
|
|
880
|
+
);
|
|
881
|
+
} catch (error) {
|
|
882
|
+
if (error instanceof PiConfigurationOperationError) {
|
|
883
|
+
sendPiConfigurationError(res, error.status, error.code, error.message);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
sendPiConfigurationError(
|
|
887
|
+
res,
|
|
888
|
+
503,
|
|
889
|
+
"inspection-failed",
|
|
890
|
+
"Pi configuration could not be inspected. Check the daemon output for details."
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (pathname === "/api/pi/auth/login" && req.method === "POST") {
|
|
896
|
+
if (origin !== allowedOrigin) {
|
|
897
|
+
sendError(res, 403, "origin-required");
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (sessionFromRequest(req) === null) {
|
|
901
|
+
sendError(res, 401, "unauthenticated");
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
await handlePiAuthOperation(
|
|
905
|
+
res,
|
|
906
|
+
async () => {
|
|
907
|
+
const parsed = startPiAuthRequestSchema.safeParse(await readJsonBody(req));
|
|
908
|
+
if (!parsed.success) {
|
|
909
|
+
throw new PiAuthOperationError(
|
|
910
|
+
"invalid-request",
|
|
911
|
+
"Choose a Pi provider and sign-in method.",
|
|
912
|
+
400
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
return piAuthOperationSchema.parse(
|
|
916
|
+
piAuth.startLogin(parsed.data.providerId, parsed.data.method)
|
|
917
|
+
);
|
|
918
|
+
},
|
|
919
|
+
201
|
|
920
|
+
);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const piAuthOperationRoute = /^\/api\/pi\/auth\/operations\/([^/]+)(?:\/(respond|cancel))?$/.exec(pathname);
|
|
924
|
+
if (piAuthOperationRoute !== null) {
|
|
925
|
+
if (sessionFromRequest(req) === null) {
|
|
926
|
+
sendError(res, 401, "unauthenticated");
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const operationId = z.uuid().safeParse(piAuthOperationRoute[1] ?? "");
|
|
930
|
+
if (!operationId.success) {
|
|
931
|
+
sendPiAuthError(res, 400, "invalid-request", "Choose a known Pi login operation.");
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const action = piAuthOperationRoute[2];
|
|
935
|
+
if (action === void 0 && req.method === "GET") {
|
|
936
|
+
await handlePiAuthOperation(
|
|
937
|
+
res,
|
|
938
|
+
() => piAuthOperationSchema.parse(piAuth.operation(operationId.data))
|
|
939
|
+
);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (req.method === "POST" && (action === "respond" || action === "cancel")) {
|
|
943
|
+
if (origin !== allowedOrigin) {
|
|
944
|
+
sendError(res, 403, "origin-required");
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
await handlePiAuthOperation(res, async () => {
|
|
948
|
+
if (action === "cancel") {
|
|
949
|
+
return piAuthOperationSchema.parse(piAuth.cancel(operationId.data));
|
|
950
|
+
}
|
|
951
|
+
const parsed = respondPiAuthRequestSchema.safeParse(
|
|
952
|
+
await readJsonBody(req, MAX_PROMPT_BODY_BYTES)
|
|
953
|
+
);
|
|
954
|
+
if (!parsed.success) {
|
|
955
|
+
throw new PiAuthOperationError(
|
|
956
|
+
"invalid-request",
|
|
957
|
+
"Provide a response for the active Pi login prompt.",
|
|
958
|
+
400
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
return piAuthOperationSchema.parse(
|
|
962
|
+
piAuth.respond(operationId.data, parsed.data.promptId, parsed.data.value)
|
|
963
|
+
);
|
|
964
|
+
});
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (pathname === "/api/repositories" && req.method === "POST") {
|
|
969
|
+
if (origin !== allowedOrigin) {
|
|
970
|
+
sendError(res, 403, "origin-required");
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (sessionFromRequest(req) === null) {
|
|
974
|
+
sendError(res, 401, "unauthenticated");
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
await handleWorkspaceOperation(res, async () => {
|
|
978
|
+
const parsed = registerRepositoryRequestSchema.safeParse(await readJsonBody(req));
|
|
979
|
+
if (!parsed.success) {
|
|
980
|
+
throw new WorkspaceOperationError(
|
|
981
|
+
"invalid-request",
|
|
982
|
+
"Choose an absolute local repository path.",
|
|
983
|
+
400
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
return workspace.registerRepository(parsed.data);
|
|
987
|
+
});
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (pathname === "/api/tasks" && req.method === "POST") {
|
|
991
|
+
if (origin !== allowedOrigin) {
|
|
992
|
+
sendError(res, 403, "origin-required");
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (sessionFromRequest(req) === null) {
|
|
996
|
+
sendError(res, 401, "unauthenticated");
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
await handleWorkspaceOperation(
|
|
1000
|
+
res,
|
|
1001
|
+
async () => {
|
|
1002
|
+
const parsed = createTaskRequestSchema.safeParse(await readJsonBody(req));
|
|
1003
|
+
if (!parsed.success) {
|
|
1004
|
+
throw new WorkspaceOperationError(
|
|
1005
|
+
"invalid-request",
|
|
1006
|
+
"Provide a repository, task title, and base revision.",
|
|
1007
|
+
400
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
return workspace.createTask(parsed.data);
|
|
1011
|
+
},
|
|
1012
|
+
201
|
|
1013
|
+
);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const taskArchiveRoute = /^\/api\/tasks\/([^/]+)\/(archive|restore)$/.exec(pathname);
|
|
1017
|
+
if (taskArchiveRoute !== null) {
|
|
1018
|
+
if (req.method !== "POST") {
|
|
1019
|
+
sendError(res, 404, "not-found");
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (origin !== allowedOrigin) {
|
|
1023
|
+
sendError(res, 403, "origin-required");
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if (sessionFromRequest(req) === null) {
|
|
1027
|
+
sendError(res, 401, "unauthenticated");
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const taskId = z.uuid().safeParse(taskArchiveRoute[1] ?? "");
|
|
1031
|
+
if (!taskId.success) {
|
|
1032
|
+
await handleWorkspaceOperation(res, async () => {
|
|
1033
|
+
throw new WorkspaceOperationError("invalid-request", "Choose a known task.", 400);
|
|
1034
|
+
});
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
await handleWorkspaceOperation(res, async () => {
|
|
1038
|
+
const request = taskArchiveRequestSchema.safeParse(await readJsonBody(req));
|
|
1039
|
+
if (!request.success) {
|
|
1040
|
+
throw new WorkspaceOperationError(
|
|
1041
|
+
"invalid-request",
|
|
1042
|
+
"Archive and restore requests do not accept additional options.",
|
|
1043
|
+
400
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
return taskArchiveRoute[2] === "archive" ? taskArchive.archive(taskId.data) : taskArchive.restore(taskId.data);
|
|
1047
|
+
});
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const taskCommitRoute = /^\/api\/tasks\/([^/]+)\/commit(?:\/(preflight))?$/.exec(pathname);
|
|
1051
|
+
if (taskCommitRoute !== null) {
|
|
1052
|
+
if (req.method !== "POST") {
|
|
1053
|
+
sendError(res, 404, "not-found");
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
if (origin !== allowedOrigin) {
|
|
1057
|
+
sendError(res, 403, "origin-required");
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (sessionFromRequest(req) === null) {
|
|
1061
|
+
sendError(res, 401, "unauthenticated");
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const taskId = z.uuid().safeParse(taskCommitRoute[1] ?? "");
|
|
1065
|
+
if (!taskId.success) {
|
|
1066
|
+
sendTaskCommitError(res, 400, "invalid-request", "Choose a known task.");
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
try {
|
|
1070
|
+
if (taskCommitRoute[2] === "preflight") {
|
|
1071
|
+
const parsed2 = taskCommitPreflightRequestSchema.safeParse(
|
|
1072
|
+
await readJsonBody(req, MAX_PROMPT_BODY_BYTES)
|
|
1073
|
+
);
|
|
1074
|
+
if (!parsed2.success) {
|
|
1075
|
+
throw new TaskCommitOperationError(
|
|
1076
|
+
"invalid-request",
|
|
1077
|
+
"Provide a non-empty commit message within Webdesk's length bound and without unsafe invisible characters.",
|
|
1078
|
+
400
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
sendJson(
|
|
1082
|
+
res,
|
|
1083
|
+
200,
|
|
1084
|
+
taskCommitPreflightSchema.parse(
|
|
1085
|
+
await taskCommit.preflight(taskId.data, parsed2.data.message)
|
|
1086
|
+
)
|
|
1087
|
+
);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
const parsed = executeTaskCommitRequestSchema.safeParse(await readJsonBody(req));
|
|
1091
|
+
if (!parsed.success) {
|
|
1092
|
+
throw new TaskCommitOperationError(
|
|
1093
|
+
"invalid-request",
|
|
1094
|
+
"Provide the current commit preflight and an explicit validation acknowledgement.",
|
|
1095
|
+
400
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
sendJson(
|
|
1099
|
+
res,
|
|
1100
|
+
200,
|
|
1101
|
+
taskCommitResultSchema.parse(await taskCommit.execute(taskId.data, parsed.data))
|
|
1102
|
+
);
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
if (error instanceof TaskCommitOperationError) {
|
|
1105
|
+
sendTaskCommitError(res, error.status, error.code, error.message);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
if (error instanceof WorkspaceOperationError) {
|
|
1109
|
+
sendTaskCommitError(res, error.status, "invalid-request", error.message);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
sendTaskCommitError(
|
|
1113
|
+
res,
|
|
1114
|
+
503,
|
|
1115
|
+
"commit-failed",
|
|
1116
|
+
"The task commit service is unavailable. Check the daemon output for details."
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const taskMergeRoute = /^\/api\/tasks\/([^/]+)\/merge(?:\/(preflight))?$/.exec(pathname);
|
|
1122
|
+
if (taskMergeRoute !== null) {
|
|
1123
|
+
if (req.method !== "POST") {
|
|
1124
|
+
sendError(res, 404, "not-found");
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
if (origin !== allowedOrigin) {
|
|
1128
|
+
sendError(res, 403, "origin-required");
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (sessionFromRequest(req) === null) {
|
|
1132
|
+
sendError(res, 401, "unauthenticated");
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
const taskId = z.uuid().safeParse(taskMergeRoute[1] ?? "");
|
|
1136
|
+
if (!taskId.success) {
|
|
1137
|
+
sendTaskMergeError(res, 400, "invalid-request", "Choose a known task.");
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
if (taskMergeRoute[2] === "preflight") {
|
|
1142
|
+
const parsed2 = taskMergePreflightRequestSchema.safeParse(
|
|
1143
|
+
await readJsonBody(req, MAX_PROMPT_BODY_BYTES)
|
|
1144
|
+
);
|
|
1145
|
+
if (!parsed2.success) {
|
|
1146
|
+
throw new TaskMergeOperationError(
|
|
1147
|
+
"invalid-request",
|
|
1148
|
+
"Provide an optional merge message within Webdesk's length bound and without unsafe invisible characters.",
|
|
1149
|
+
400
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
sendJson(
|
|
1153
|
+
res,
|
|
1154
|
+
200,
|
|
1155
|
+
taskMergePreflightSchema.parse(await taskMerge.preflight(taskId.data, parsed2.data))
|
|
1156
|
+
);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
const parsed = executeTaskMergeRequestSchema.safeParse(await readJsonBody(req));
|
|
1160
|
+
if (!parsed.success) {
|
|
1161
|
+
throw new TaskMergeOperationError(
|
|
1162
|
+
"invalid-request",
|
|
1163
|
+
"Provide the current merge preflight and an explicit validation acknowledgement.",
|
|
1164
|
+
400
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
sendJson(
|
|
1168
|
+
res,
|
|
1169
|
+
200,
|
|
1170
|
+
taskMergeResultSchema.parse(await taskMerge.execute(taskId.data, parsed.data))
|
|
1171
|
+
);
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
handleTaskMergeFailure(res, error);
|
|
1174
|
+
}
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
const repositoryMergeSyncRoute = /^\/api\/repositories\/([^/]+)\/merge-sync$/.exec(pathname);
|
|
1178
|
+
if (repositoryMergeSyncRoute !== null) {
|
|
1179
|
+
if (req.method !== "POST") {
|
|
1180
|
+
sendError(res, 404, "not-found");
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (origin !== allowedOrigin) {
|
|
1184
|
+
sendError(res, 403, "origin-required");
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
if (sessionFromRequest(req) === null) {
|
|
1188
|
+
sendError(res, 401, "unauthenticated");
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
const repositoryId = z.uuid().safeParse(repositoryMergeSyncRoute[1] ?? "");
|
|
1192
|
+
if (!repositoryId.success) {
|
|
1193
|
+
sendTaskMergeError(res, 400, "invalid-request", "Choose a known repository.");
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
try {
|
|
1197
|
+
const request = repositoryMergeSyncRequestSchema.safeParse(await readJsonBody(req));
|
|
1198
|
+
if (!request.success) {
|
|
1199
|
+
throw new TaskMergeOperationError(
|
|
1200
|
+
"invalid-request",
|
|
1201
|
+
"Choose whether an inspected externally moved target should dismiss merge recovery.",
|
|
1202
|
+
400
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
sendJson(
|
|
1206
|
+
res,
|
|
1207
|
+
200,
|
|
1208
|
+
repositoryMergeSyncResultSchema.parse(
|
|
1209
|
+
await taskMerge.syncRepository(repositoryId.data, request.data)
|
|
1210
|
+
)
|
|
1211
|
+
);
|
|
1212
|
+
} catch (error) {
|
|
1213
|
+
handleTaskMergeFailure(res, error);
|
|
1214
|
+
}
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const taskRuntimeRoute = /^\/api\/tasks\/([^/]+)\/(runtime|restart|model|prompt|steer|abort|approval|session-tree|review|validation)$/.exec(
|
|
1218
|
+
pathname
|
|
1219
|
+
);
|
|
1220
|
+
if (taskRuntimeRoute !== null && taskRuntimeRoute[2] === "review" && req.method === "GET") {
|
|
1221
|
+
if (sessionFromRequest(req) === null) {
|
|
1222
|
+
sendError(res, 401, "unauthenticated");
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const taskId = z.uuid().safeParse(taskRuntimeRoute[1] ?? "");
|
|
1226
|
+
if (!taskId.success) {
|
|
1227
|
+
sendTaskReviewError(res, 400, "invalid-request", "Choose a known task.");
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
try {
|
|
1231
|
+
sendJson(res, 200, taskReviewSchema.parse(await taskReview.review(taskId.data)));
|
|
1232
|
+
} catch (error) {
|
|
1233
|
+
if (error instanceof TaskReviewOperationError) {
|
|
1234
|
+
sendTaskReviewError(res, error.status, error.code, error.message);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
sendTaskReviewError(
|
|
1238
|
+
res,
|
|
1239
|
+
503,
|
|
1240
|
+
"review-failed",
|
|
1241
|
+
"The task review service is unavailable. Check the daemon output for details."
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (taskRuntimeRoute !== null && taskRuntimeRoute[2] === "validation") {
|
|
1247
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
1248
|
+
sendError(res, 404, "not-found");
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
if (req.method === "POST" && origin !== allowedOrigin) {
|
|
1252
|
+
sendError(res, 403, "origin-required");
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
if (sessionFromRequest(req) === null) {
|
|
1256
|
+
sendError(res, 401, "unauthenticated");
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
const taskId = z.uuid().safeParse(taskRuntimeRoute[1] ?? "");
|
|
1260
|
+
if (!taskId.success) {
|
|
1261
|
+
sendTaskValidationError(res, 400, "invalid-request", "Choose a known task.");
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
try {
|
|
1265
|
+
if (req.method === "GET") {
|
|
1266
|
+
sendJson(res, 200, taskValidationSchema.parse(await taskValidation.status(taskId.data)));
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
const parsed = runTaskValidationRequestSchema.safeParse(
|
|
1270
|
+
await readJsonBody(req, MAX_PROMPT_BODY_BYTES)
|
|
1271
|
+
);
|
|
1272
|
+
if (!parsed.success) {
|
|
1273
|
+
throw new TaskValidationOperationError(
|
|
1274
|
+
"invalid-request",
|
|
1275
|
+
"Provide a non-empty validation command within Webdesk's length bound and without unsafe invisible characters.",
|
|
1276
|
+
400
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
sendJson(
|
|
1280
|
+
res,
|
|
1281
|
+
200,
|
|
1282
|
+
taskValidationSchema.parse(await taskValidation.run(taskId.data, parsed.data.command))
|
|
1283
|
+
);
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
if (error instanceof TaskValidationOperationError) {
|
|
1286
|
+
sendTaskValidationError(res, error.status, error.code, error.message);
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
if (error instanceof WorkspaceOperationError) {
|
|
1290
|
+
sendTaskValidationError(res, error.status, "invalid-request", error.message);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
sendTaskValidationError(
|
|
1294
|
+
res,
|
|
1295
|
+
503,
|
|
1296
|
+
"validation-failed",
|
|
1297
|
+
"The task validation service is unavailable. Check the daemon output for details."
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
if (taskRuntimeRoute !== null && (taskRuntimeRoute[2] === "runtime" || taskRuntimeRoute[2] === "session-tree") && req.method === "GET") {
|
|
1303
|
+
if (sessionFromRequest(req) === null) {
|
|
1304
|
+
sendError(res, 401, "unauthenticated");
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
const taskId = z.uuid().safeParse(taskRuntimeRoute[1] ?? "");
|
|
1308
|
+
if (!taskId.success) {
|
|
1309
|
+
sendTaskRuntimeError(res, 400, "invalid-request", "Choose a known task.");
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
try {
|
|
1313
|
+
if (taskRuntimeRoute[2] === "session-tree") {
|
|
1314
|
+
sendJson(
|
|
1315
|
+
res,
|
|
1316
|
+
200,
|
|
1317
|
+
taskSessionTreeSchema.parse(await runtimeCoordinator.sessionTree(taskId.data))
|
|
1318
|
+
);
|
|
1319
|
+
} else {
|
|
1320
|
+
const status = await runtimeCoordinator.status(taskId.data);
|
|
1321
|
+
const recoveryIssue = runtimeRecoveryIssues.get(taskId.data);
|
|
1322
|
+
sendJson(
|
|
1323
|
+
res,
|
|
1324
|
+
200,
|
|
1325
|
+
taskRuntimeStatusSchema.parse(
|
|
1326
|
+
recoveryIssue === void 0 || status.state !== "idle" && status.state !== "exited" && status.state !== "failed" ? status : {
|
|
1327
|
+
...status,
|
|
1328
|
+
detail: `Runtime recovery required: ${recoveryIssue} Stop any remaining task processes, then choose Start Pi to retry.`
|
|
1329
|
+
}
|
|
1330
|
+
)
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
if (error instanceof TaskRuntimeOperationError) {
|
|
1335
|
+
sendTaskRuntimeError(res, error.status, error.code, error.message);
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
sendTaskRuntimeError(
|
|
1339
|
+
res,
|
|
1340
|
+
503,
|
|
1341
|
+
"runtime-unavailable",
|
|
1342
|
+
"The task runtime service is unavailable. Check the daemon output for details."
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
if (taskRuntimeRoute !== null && taskRuntimeRoute[2] !== "session-tree" && taskRuntimeRoute[2] !== "review" && req.method === "POST") {
|
|
1348
|
+
if (origin !== allowedOrigin) {
|
|
1349
|
+
sendError(res, 403, "origin-required");
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
if (sessionFromRequest(req) === null) {
|
|
1353
|
+
sendError(res, 401, "unauthenticated");
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const taskId = z.uuid().safeParse(taskRuntimeRoute[1] ?? "");
|
|
1357
|
+
if (!taskId.success) {
|
|
1358
|
+
sendTaskRuntimeError(res, 400, "invalid-request", "Choose a known task.");
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
const action = taskRuntimeRoute[2];
|
|
1362
|
+
try {
|
|
1363
|
+
let status;
|
|
1364
|
+
if (action === "runtime") {
|
|
1365
|
+
status = await runtimeCoordinator.ensure(taskId.data);
|
|
1366
|
+
} else if (action === "restart") {
|
|
1367
|
+
status = await runtimeCoordinator.restart(taskId.data);
|
|
1368
|
+
} else if (action === "model") {
|
|
1369
|
+
const parsed = selectTaskModelRequestSchema.safeParse(await readJsonBody(req));
|
|
1370
|
+
if (!parsed.success) {
|
|
1371
|
+
throw new TaskRuntimeOperationError(
|
|
1372
|
+
"invalid-request",
|
|
1373
|
+
"Choose an available Pi model.",
|
|
1374
|
+
400
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
status = await runtimeCoordinator.setModel(
|
|
1378
|
+
taskId.data,
|
|
1379
|
+
parsed.data.provider,
|
|
1380
|
+
parsed.data.modelId
|
|
1381
|
+
);
|
|
1382
|
+
} else if (action === "prompt") {
|
|
1383
|
+
const parsed = promptTaskRequestSchema.safeParse(
|
|
1384
|
+
await readJsonBody(req, MAX_PROMPT_BODY_BYTES)
|
|
1385
|
+
);
|
|
1386
|
+
if (!parsed.success) {
|
|
1387
|
+
throw new TaskRuntimeOperationError(
|
|
1388
|
+
"invalid-request",
|
|
1389
|
+
"Provide a non-empty prompt message.",
|
|
1390
|
+
400
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
if (taskValidation.isRunning(taskId.data) || taskCommit.isBusy(taskId.data) || taskMerge.isBusy(taskId.data)) {
|
|
1394
|
+
throw new TaskRuntimeOperationError(
|
|
1395
|
+
"runtime-busy",
|
|
1396
|
+
"A validation, commit, or merge operation is active for this task. Wait for it to finish before prompting Pi.",
|
|
1397
|
+
409
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
status = await runtimeCoordinator.prompt(taskId.data, parsed.data.message);
|
|
1401
|
+
} else if (action === "steer") {
|
|
1402
|
+
const parsed = steerTaskRequestSchema.safeParse(
|
|
1403
|
+
await readJsonBody(req, MAX_PROMPT_BODY_BYTES)
|
|
1404
|
+
);
|
|
1405
|
+
if (!parsed.success) {
|
|
1406
|
+
throw new TaskRuntimeOperationError(
|
|
1407
|
+
"invalid-request",
|
|
1408
|
+
"Provide a non-empty steering message.",
|
|
1409
|
+
400
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
status = await runtimeCoordinator.steer(taskId.data, parsed.data.message);
|
|
1413
|
+
} else if (action === "approval") {
|
|
1414
|
+
const parsed = resolveTaskApprovalRequestSchema.safeParse(await readJsonBody(req));
|
|
1415
|
+
if (!parsed.success) {
|
|
1416
|
+
throw new TaskRuntimeOperationError(
|
|
1417
|
+
"invalid-request",
|
|
1418
|
+
"Choose the current approval and an explicit decision.",
|
|
1419
|
+
400
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
status = await runtimeCoordinator.resolveApproval(
|
|
1423
|
+
taskId.data,
|
|
1424
|
+
parsed.data.approvalId,
|
|
1425
|
+
parsed.data.decision
|
|
1426
|
+
);
|
|
1427
|
+
} else {
|
|
1428
|
+
status = await runtimeCoordinator.abort(taskId.data);
|
|
1429
|
+
}
|
|
1430
|
+
sendJson(res, 200, taskRuntimeStatusSchema.parse(status));
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
if (error instanceof TaskRuntimeOperationError) {
|
|
1433
|
+
sendTaskRuntimeError(res, error.status, error.code, error.message);
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
if (error instanceof WorkspaceOperationError) {
|
|
1437
|
+
sendTaskRuntimeError(res, error.status, "invalid-request", error.message);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
sendTaskRuntimeError(
|
|
1441
|
+
res,
|
|
1442
|
+
503,
|
|
1443
|
+
"runtime-unavailable",
|
|
1444
|
+
"The task runtime service is unavailable. Check the daemon output for details."
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
sendError(res, 404, "not-found");
|
|
1450
|
+
}
|
|
1451
|
+
function rejectUpgrade(socket, status, reason) {
|
|
1452
|
+
socket.end(`HTTP/1.1 ${status} ${reason}\r
|
|
1453
|
+
Connection: close\r
|
|
1454
|
+
Content-Length: 0\r
|
|
1455
|
+
\r
|
|
1456
|
+
`);
|
|
1457
|
+
}
|
|
1458
|
+
function handleUpgrade(req, socket, head) {
|
|
1459
|
+
socket.on("error", () => socket.destroy());
|
|
1460
|
+
const pathname = new URL(req.url ?? "/", `http://${LOOPBACK_ADDRESS}`).pathname;
|
|
1461
|
+
if (pathname !== "/api/events") {
|
|
1462
|
+
rejectUpgrade(socket, 404, "Not Found");
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
if (!hostAllowed(req)) {
|
|
1466
|
+
rejectUpgrade(socket, 403, "Forbidden");
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
if (req.headers.origin !== allowedOrigin) {
|
|
1470
|
+
rejectUpgrade(socket, 403, "Forbidden");
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
const claims = sessionFromRequest(req);
|
|
1474
|
+
if (claims === null) {
|
|
1475
|
+
rejectUpgrade(socket, 401, "Unauthorized");
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1479
|
+
ws.on("error", () => ws.terminate());
|
|
1480
|
+
const expiryDelayMs = Math.max(0, claims.expiresAt - now());
|
|
1481
|
+
const expiryTimer = setTimeout(() => {
|
|
1482
|
+
if (sockets.has(ws)) {
|
|
1483
|
+
ws.close(4001, "session-expired");
|
|
1484
|
+
}
|
|
1485
|
+
}, expiryDelayMs);
|
|
1486
|
+
expiryTimer.unref?.();
|
|
1487
|
+
sockets.set(ws, expiryTimer);
|
|
1488
|
+
const channel = eventHub.connect((message) => {
|
|
1489
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1490
|
+
ws.send(JSON.stringify(message));
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
ws.on("message", (data, isBinary) => {
|
|
1494
|
+
let parsed;
|
|
1495
|
+
try {
|
|
1496
|
+
parsed = safeParseClientMessage(JSON.parse(isBinary ? "" : String(data)));
|
|
1497
|
+
} catch {
|
|
1498
|
+
ws.close(1008, "invalid-message");
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
if (!parsed.success) {
|
|
1502
|
+
ws.close(1008, "invalid-message");
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
channel.subscribe(parsed.data);
|
|
1506
|
+
});
|
|
1507
|
+
ws.on("close", () => {
|
|
1508
|
+
channel.close();
|
|
1509
|
+
const timer = sockets.get(ws);
|
|
1510
|
+
if (timer !== void 0) {
|
|
1511
|
+
clearTimeout(timer);
|
|
1512
|
+
}
|
|
1513
|
+
sockets.delete(ws);
|
|
1514
|
+
});
|
|
1515
|
+
ws.send(
|
|
1516
|
+
JSON.stringify(
|
|
1517
|
+
serverControlMessageSchema.parse({
|
|
1518
|
+
protocol: PROTOCOL_VERSION,
|
|
1519
|
+
type: "service-ready",
|
|
1520
|
+
timestamp: now(),
|
|
1521
|
+
sessionExpiresAt: claims.expiresAt
|
|
1522
|
+
})
|
|
1523
|
+
)
|
|
1524
|
+
);
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
const httpServer = createServer((req, res) => {
|
|
1528
|
+
const operation = handleRequest(req, res).catch(() => {
|
|
1529
|
+
if (!res.headersSent) {
|
|
1530
|
+
sendError(res, 500, "internal-error");
|
|
1531
|
+
} else {
|
|
1532
|
+
res.end();
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
activeRequests.add(operation);
|
|
1536
|
+
void operation.then(
|
|
1537
|
+
() => activeRequests.delete(operation),
|
|
1538
|
+
() => activeRequests.delete(operation)
|
|
1539
|
+
);
|
|
1540
|
+
});
|
|
1541
|
+
httpServer.on("upgrade", handleUpgrade);
|
|
1542
|
+
return {
|
|
1543
|
+
get bootstrapToken() {
|
|
1544
|
+
return bootstrapToken;
|
|
1545
|
+
},
|
|
1546
|
+
rotateBootstrapToken,
|
|
1547
|
+
listen() {
|
|
1548
|
+
if (closed) {
|
|
1549
|
+
return Promise.reject(new DaemonConfigError("Daemon server is closed"));
|
|
1550
|
+
}
|
|
1551
|
+
if (listenOperation !== null || releaseStateLock !== null || httpServer.listening) {
|
|
1552
|
+
return Promise.reject(
|
|
1553
|
+
new DaemonConfigError("Daemon server is already starting or listening")
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
const operation = (async () => {
|
|
1557
|
+
let acquiredLock = null;
|
|
1558
|
+
try {
|
|
1559
|
+
acquiredLock = await acquireDaemonStateLock(canonicalStateDir);
|
|
1560
|
+
if (closed) throw new DaemonConfigError("Daemon server is closed");
|
|
1561
|
+
releaseStateLock = acquiredLock;
|
|
1562
|
+
await workspace.initialize();
|
|
1563
|
+
if (closed) throw new DaemonConfigError("Daemon server is closed");
|
|
1564
|
+
const startupSnapshot = await workspace.snapshot();
|
|
1565
|
+
for (const task of startupSnapshot.state.tasks) {
|
|
1566
|
+
if (task.status !== "ready" || task.runtimeProcess === void 0) continue;
|
|
1567
|
+
try {
|
|
1568
|
+
await reconcileReadyTaskRuntimeProcess(task);
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
if (error instanceof RuntimeProcessRecoveryError) continue;
|
|
1571
|
+
throw error;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
if (closed) throw new DaemonConfigError("Daemon server is closed");
|
|
1575
|
+
return await new Promise((resolve, reject) => {
|
|
1576
|
+
const onError = (error) => reject(error);
|
|
1577
|
+
httpServer.once("error", onError);
|
|
1578
|
+
httpServer.listen(port, LOOPBACK_ADDRESS, () => {
|
|
1579
|
+
httpServer.removeListener("error", onError);
|
|
1580
|
+
if (closed) {
|
|
1581
|
+
httpServer.close(() => reject(new DaemonConfigError("Daemon server is closed")));
|
|
1582
|
+
httpServer.closeAllConnections();
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
const address = httpServer.address();
|
|
1586
|
+
if (address === null || typeof address === "string") {
|
|
1587
|
+
reject(new Error("daemon server did not bind to a TCP port"));
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
boundPort = address.port;
|
|
1591
|
+
resolve({ address: address.address, port: address.port });
|
|
1592
|
+
});
|
|
1593
|
+
});
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
if (releaseStateLock === acquiredLock) {
|
|
1596
|
+
releaseStateLock = null;
|
|
1597
|
+
}
|
|
1598
|
+
await acquiredLock?.().catch(() => void 0);
|
|
1599
|
+
throw error;
|
|
1600
|
+
}
|
|
1601
|
+
})();
|
|
1602
|
+
listenOperation = operation;
|
|
1603
|
+
void operation.then(
|
|
1604
|
+
() => {
|
|
1605
|
+
if (listenOperation === operation) listenOperation = null;
|
|
1606
|
+
},
|
|
1607
|
+
() => {
|
|
1608
|
+
if (listenOperation === operation) listenOperation = null;
|
|
1609
|
+
}
|
|
1610
|
+
);
|
|
1611
|
+
return operation;
|
|
1612
|
+
},
|
|
1613
|
+
close() {
|
|
1614
|
+
if (closeOperation !== null) return closeOperation;
|
|
1615
|
+
closed = true;
|
|
1616
|
+
const pendingListen = listenOperation;
|
|
1617
|
+
closeOperation = (async () => {
|
|
1618
|
+
await pendingListen?.catch(() => void 0);
|
|
1619
|
+
for (const [ws, timer] of sockets) {
|
|
1620
|
+
clearTimeout(timer);
|
|
1621
|
+
ws.terminate();
|
|
1622
|
+
}
|
|
1623
|
+
sockets.clear();
|
|
1624
|
+
wss.close();
|
|
1625
|
+
const runtimeDisposal = runtimeCoordinator.disposeAll();
|
|
1626
|
+
const validationDisposal = taskValidation.disposeAll();
|
|
1627
|
+
piAuth.dispose();
|
|
1628
|
+
if (httpServer.listening) {
|
|
1629
|
+
await new Promise((resolve, reject) => {
|
|
1630
|
+
httpServer.close((error) => {
|
|
1631
|
+
if (error) {
|
|
1632
|
+
reject(error);
|
|
1633
|
+
} else {
|
|
1634
|
+
resolve();
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
httpServer.closeAllConnections();
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
await runtimeDisposal;
|
|
1641
|
+
await validationDisposal;
|
|
1642
|
+
await Promise.allSettled([...activeRequests]);
|
|
1643
|
+
boundPort = null;
|
|
1644
|
+
const release = releaseStateLock;
|
|
1645
|
+
releaseStateLock = null;
|
|
1646
|
+
await release?.();
|
|
1647
|
+
})();
|
|
1648
|
+
return closeOperation;
|
|
1649
|
+
},
|
|
1650
|
+
injectConnection(socket) {
|
|
1651
|
+
if (closed) {
|
|
1652
|
+
socket.destroy();
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
httpServer.emit("connection", socket);
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
export {
|
|
1660
|
+
DaemonConfigError,
|
|
1661
|
+
createDaemonServer
|
|
1662
|
+
};
|