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,795 @@
|
|
|
1
|
+
// apps/daemon/src/workspace.ts
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
GitWorktreeIncompleteError,
|
|
7
|
+
createTaskWorktree,
|
|
8
|
+
inspectRepository,
|
|
9
|
+
isGitServiceError,
|
|
10
|
+
preflightTaskWorktree
|
|
11
|
+
} from "../../../packages/git/src/index.js";
|
|
12
|
+
import {
|
|
13
|
+
PROTOCOL_VERSION,
|
|
14
|
+
workspaceSnapshotSchema,
|
|
15
|
+
workspaceStateSchema
|
|
16
|
+
} from "../../../packages/protocol/src/index.js";
|
|
17
|
+
import { createWorkspaceStore } from "./workspace-store.js";
|
|
18
|
+
var WorkspaceOperationError = class extends Error {
|
|
19
|
+
name = "WorkspaceOperationError";
|
|
20
|
+
code;
|
|
21
|
+
status;
|
|
22
|
+
constructor(code, message, status, options) {
|
|
23
|
+
super(message, options);
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.status = status;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
function snapshot(state) {
|
|
29
|
+
return workspaceSnapshotSchema.parse({
|
|
30
|
+
protocol: PROTOCOL_VERSION,
|
|
31
|
+
type: "workspace-snapshot",
|
|
32
|
+
state
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function slugify(title) {
|
|
36
|
+
const slug = title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48).replace(/-+$/g, "");
|
|
37
|
+
return slug || "task";
|
|
38
|
+
}
|
|
39
|
+
var CONFLICT_CODES = /* @__PURE__ */ new Set([
|
|
40
|
+
"GIT_BRANCH_EXISTS",
|
|
41
|
+
"GIT_WORKTREE_TARGET_EXISTS",
|
|
42
|
+
"GIT_WORKTREE_TARGET_REGISTERED"
|
|
43
|
+
]);
|
|
44
|
+
function gitError(error) {
|
|
45
|
+
if (error instanceof GitWorktreeIncompleteError) {
|
|
46
|
+
return new WorkspaceOperationError(
|
|
47
|
+
"git-incomplete",
|
|
48
|
+
"Git may have created only part of the task worktree. Webdesk preserved it for recovery.",
|
|
49
|
+
409,
|
|
50
|
+
{ cause: error }
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (isGitServiceError(error)) {
|
|
54
|
+
return new WorkspaceOperationError(
|
|
55
|
+
CONFLICT_CODES.has(error.code) ? "git-conflict" : "git-preflight-failed",
|
|
56
|
+
error.message,
|
|
57
|
+
CONFLICT_CODES.has(error.code) ? 409 : 422,
|
|
58
|
+
{ cause: error }
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (error instanceof WorkspaceOperationError) {
|
|
62
|
+
return error;
|
|
63
|
+
}
|
|
64
|
+
return new WorkspaceOperationError(
|
|
65
|
+
"workspace-unavailable",
|
|
66
|
+
"The local workspace service could not complete the operation.",
|
|
67
|
+
503,
|
|
68
|
+
{ cause: error }
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
function failedTask(task, error) {
|
|
72
|
+
const mapped = gitError(error);
|
|
73
|
+
const recoveryFacts = error instanceof GitWorktreeIncompleteError ? error.facts : void 0;
|
|
74
|
+
return {
|
|
75
|
+
id: task.id,
|
|
76
|
+
repositoryId: task.repositoryId,
|
|
77
|
+
title: task.title,
|
|
78
|
+
branch: task.branch,
|
|
79
|
+
worktreePath: task.worktreePath,
|
|
80
|
+
baseCommit: task.baseCommit,
|
|
81
|
+
createdAtMs: task.createdAtMs,
|
|
82
|
+
status: "failed",
|
|
83
|
+
failure: {
|
|
84
|
+
code: mapped.code,
|
|
85
|
+
message: mapped.message,
|
|
86
|
+
...recoveryFacts ? { recoveryFacts } : {}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function interruptedTask(task, targetExistsOnDisk) {
|
|
91
|
+
return {
|
|
92
|
+
...task,
|
|
93
|
+
status: "failed",
|
|
94
|
+
failure: {
|
|
95
|
+
code: "interrupted-provisioning",
|
|
96
|
+
message: "The daemon stopped while this worktree was being created. Inspect the branch and path before recovering it.",
|
|
97
|
+
recoveryFacts: {
|
|
98
|
+
branch: task.branch,
|
|
99
|
+
worktreePath: task.worktreePath,
|
|
100
|
+
baseCommit: task.baseCommit,
|
|
101
|
+
branchExists: null,
|
|
102
|
+
targetRegisteredAsWorktree: null,
|
|
103
|
+
targetExistsOnDisk
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function createWorkspaceService(options) {
|
|
109
|
+
const stateDir = path.resolve(options.stateDir);
|
|
110
|
+
const store = options.store ?? createWorkspaceStore({ stateDir });
|
|
111
|
+
const git = options.git ?? {
|
|
112
|
+
inspectRepository,
|
|
113
|
+
preflightTaskWorktree,
|
|
114
|
+
createTaskWorktree
|
|
115
|
+
};
|
|
116
|
+
const now = options.now ?? Date.now;
|
|
117
|
+
const createId = options.createId ?? randomUUID;
|
|
118
|
+
let initialization = null;
|
|
119
|
+
let canonicalStateRoot = null;
|
|
120
|
+
function expectedTaskWorktreePath(repositoryId, taskId) {
|
|
121
|
+
if (canonicalStateRoot === null) {
|
|
122
|
+
throw new WorkspaceOperationError(
|
|
123
|
+
"workspace-unavailable",
|
|
124
|
+
"Webdesk could not resolve its canonical state directory.",
|
|
125
|
+
503
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return path.join(canonicalStateRoot, "worktrees", repositoryId, taskId);
|
|
129
|
+
}
|
|
130
|
+
async function ensureRealOwnedDirectory(directoryPath) {
|
|
131
|
+
try {
|
|
132
|
+
await mkdir(directoryPath, { mode: 448 });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error.code !== "EEXIST") throw error;
|
|
135
|
+
}
|
|
136
|
+
const state = await lstat(directoryPath);
|
|
137
|
+
if (!state.isDirectory() || state.isSymbolicLink()) {
|
|
138
|
+
throw new Error(`${directoryPath} is not a real directory`);
|
|
139
|
+
}
|
|
140
|
+
if (await realpath(directoryPath) !== directoryPath) {
|
|
141
|
+
throw new Error(`${directoryPath} resolves outside its canonical location`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function prepareTaskWorktreeParent(repositoryId) {
|
|
145
|
+
if (canonicalStateRoot === null) {
|
|
146
|
+
throw new WorkspaceOperationError(
|
|
147
|
+
"workspace-unavailable",
|
|
148
|
+
"Webdesk could not resolve its canonical state directory.",
|
|
149
|
+
503
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const worktreesRoot = path.join(canonicalStateRoot, "worktrees");
|
|
154
|
+
await ensureRealOwnedDirectory(worktreesRoot);
|
|
155
|
+
await ensureRealOwnedDirectory(path.join(worktreesRoot, repositoryId));
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throw new WorkspaceOperationError(
|
|
158
|
+
"git-preflight-failed",
|
|
159
|
+
"Webdesk refused the task because its daemon-owned worktree parent is missing, symlinked, or outside the canonical state directory.",
|
|
160
|
+
409,
|
|
161
|
+
{ cause: error }
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function reconcileProvisioningTask(task, state, canonicalStateDir) {
|
|
166
|
+
let canonicalWorktreePath;
|
|
167
|
+
try {
|
|
168
|
+
canonicalWorktreePath = await realpath(task.worktreePath);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const code = error.code;
|
|
171
|
+
return interruptedTask(task, code === "ENOENT" || code === "ENOTDIR" ? false : null);
|
|
172
|
+
}
|
|
173
|
+
const expectedWorktreePath = path.join(
|
|
174
|
+
canonicalStateDir,
|
|
175
|
+
"worktrees",
|
|
176
|
+
task.repositoryId,
|
|
177
|
+
task.id
|
|
178
|
+
);
|
|
179
|
+
if (canonicalWorktreePath !== expectedWorktreePath) return interruptedTask(task, true);
|
|
180
|
+
const repository = state.repositories.find((record) => record.id === task.repositoryId);
|
|
181
|
+
if (!repository) return interruptedTask(task, true);
|
|
182
|
+
try {
|
|
183
|
+
const worktree = await git.inspectRepository(canonicalWorktreePath);
|
|
184
|
+
if (worktree.root === canonicalWorktreePath && worktree.commonDir === repository.commonDir && worktree.branch === task.branch && worktree.headCommit === task.baseCommit) {
|
|
185
|
+
return { ...task, worktreePath: canonicalWorktreePath, status: "ready" };
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
}
|
|
189
|
+
return interruptedTask(task, true);
|
|
190
|
+
}
|
|
191
|
+
async function canonicalizeRecordedTaskPath(task, state, canonicalStateDir) {
|
|
192
|
+
if (task.status !== "ready" && task.status !== "archived") return task;
|
|
193
|
+
const expectedWorktreePath = path.join(
|
|
194
|
+
canonicalStateDir,
|
|
195
|
+
"worktrees",
|
|
196
|
+
task.repositoryId,
|
|
197
|
+
task.id
|
|
198
|
+
);
|
|
199
|
+
const legacyConfiguredPath = path.join(
|
|
200
|
+
stateDir,
|
|
201
|
+
"worktrees",
|
|
202
|
+
task.repositoryId,
|
|
203
|
+
task.id
|
|
204
|
+
);
|
|
205
|
+
let canonicalWorktreePath;
|
|
206
|
+
try {
|
|
207
|
+
canonicalWorktreePath = await realpath(task.worktreePath);
|
|
208
|
+
} catch {
|
|
209
|
+
if (task.worktreePath !== expectedWorktreePath && task.worktreePath !== legacyConfiguredPath) {
|
|
210
|
+
throw new WorkspaceOperationError(
|
|
211
|
+
"workspace-unavailable",
|
|
212
|
+
`Task ${task.id} records a path outside its daemon-owned worktree slot. Webdesk preserved the workspace metadata and refused startup.`,
|
|
213
|
+
503
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return task;
|
|
217
|
+
}
|
|
218
|
+
if (canonicalWorktreePath !== expectedWorktreePath) {
|
|
219
|
+
throw new WorkspaceOperationError(
|
|
220
|
+
"workspace-unavailable",
|
|
221
|
+
`Task ${task.id} resolves outside its daemon-owned worktree path. Webdesk preserved the workspace metadata and refused startup.`,
|
|
222
|
+
503
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (canonicalWorktreePath === task.worktreePath) return task;
|
|
226
|
+
const repository = state.repositories.find((record) => record.id === task.repositoryId);
|
|
227
|
+
if (repository === void 0) return task;
|
|
228
|
+
try {
|
|
229
|
+
const worktree = await git.inspectRepository(canonicalWorktreePath);
|
|
230
|
+
if (worktree.root === canonicalWorktreePath && worktree.commonDir === repository.commonDir && worktree.branch === task.branch) {
|
|
231
|
+
return { ...task, worktreePath: canonicalWorktreePath };
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
}
|
|
235
|
+
return task;
|
|
236
|
+
}
|
|
237
|
+
async function verifyTaskWorktree(taskId) {
|
|
238
|
+
await initialize();
|
|
239
|
+
const state = await store.read();
|
|
240
|
+
const task = state.tasks.find((candidate) => candidate.id === taskId);
|
|
241
|
+
if (task === void 0 || task.status !== "ready") {
|
|
242
|
+
throw new WorkspaceOperationError(
|
|
243
|
+
task === void 0 ? "task-not-found" : "task-not-ready",
|
|
244
|
+
"Only a ready task can start Pi.",
|
|
245
|
+
task === void 0 ? 404 : 409
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const expectedWorktreePath = expectedTaskWorktreePath(task.repositoryId, task.id);
|
|
249
|
+
if (task.worktreePath !== expectedWorktreePath) {
|
|
250
|
+
throw new WorkspaceOperationError(
|
|
251
|
+
"task-recovery-required",
|
|
252
|
+
"The task worktree is not recorded at its daemon-owned canonical path.",
|
|
253
|
+
409
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const repository = state.repositories.find((candidate) => candidate.id === task.repositoryId);
|
|
257
|
+
if (repository === void 0) {
|
|
258
|
+
throw new WorkspaceOperationError(
|
|
259
|
+
"task-recovery-required",
|
|
260
|
+
"The task's registered repository is unavailable.",
|
|
261
|
+
409
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
let canonicalWorktreePath;
|
|
265
|
+
let worktree;
|
|
266
|
+
try {
|
|
267
|
+
canonicalWorktreePath = await realpath(task.worktreePath);
|
|
268
|
+
if (canonicalWorktreePath !== expectedWorktreePath) throw new Error("worktree retargeted");
|
|
269
|
+
worktree = await git.inspectRepository(canonicalWorktreePath);
|
|
270
|
+
if (await realpath(task.worktreePath) !== canonicalWorktreePath) {
|
|
271
|
+
throw new Error("worktree changed while it was being verified");
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
throw new WorkspaceOperationError(
|
|
275
|
+
"task-recovery-required",
|
|
276
|
+
"The task worktree is unavailable or no longer at its daemon-owned canonical path.",
|
|
277
|
+
409,
|
|
278
|
+
{ cause: error }
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (worktree.root !== canonicalWorktreePath || worktree.commonDir !== repository.commonDir || worktree.branch !== task.branch) {
|
|
282
|
+
throw new WorkspaceOperationError(
|
|
283
|
+
"task-recovery-required",
|
|
284
|
+
"The task worktree no longer matches its recorded repository or branch.",
|
|
285
|
+
409
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return canonicalWorktreePath;
|
|
289
|
+
}
|
|
290
|
+
function initialize() {
|
|
291
|
+
initialization ??= (async () => {
|
|
292
|
+
await store.initialize();
|
|
293
|
+
const current = await store.read();
|
|
294
|
+
const canonicalStateDir = await realpath(stateDir);
|
|
295
|
+
canonicalStateRoot = canonicalStateDir;
|
|
296
|
+
const reconciled = await Promise.all(
|
|
297
|
+
current.tasks.map(async (task) => {
|
|
298
|
+
if (task.status === "provisioning") {
|
|
299
|
+
return reconcileProvisioningTask(task, current, canonicalStateDir);
|
|
300
|
+
}
|
|
301
|
+
return canonicalizeRecordedTaskPath(task, current, canonicalStateDir);
|
|
302
|
+
})
|
|
303
|
+
);
|
|
304
|
+
if (reconciled.every((task, index) => task === current.tasks[index])) return;
|
|
305
|
+
const byId = new Map(reconciled.map((task) => [task.id, task]));
|
|
306
|
+
await store.update((state) => ({
|
|
307
|
+
...state,
|
|
308
|
+
tasks: state.tasks.map((task) => byId.get(task.id) ?? task)
|
|
309
|
+
}));
|
|
310
|
+
})();
|
|
311
|
+
return initialization;
|
|
312
|
+
}
|
|
313
|
+
async function registerRepository(request) {
|
|
314
|
+
await initialize();
|
|
315
|
+
let repository;
|
|
316
|
+
try {
|
|
317
|
+
repository = await git.inspectRepository(request.path);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
throw gitError(error);
|
|
320
|
+
}
|
|
321
|
+
const next = await store.update((current) => {
|
|
322
|
+
const existing = current.repositories.find(
|
|
323
|
+
(record) => record.commonDir === repository.commonDir
|
|
324
|
+
);
|
|
325
|
+
if (existing) {
|
|
326
|
+
if (existing.rootPath !== repository.root) {
|
|
327
|
+
throw new WorkspaceOperationError(
|
|
328
|
+
"repository-already-registered",
|
|
329
|
+
`This Git repository is already registered through ${existing.rootPath}.`,
|
|
330
|
+
409
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
...current,
|
|
335
|
+
repositories: current.repositories.map(
|
|
336
|
+
(record) => record.id === existing.id ? {
|
|
337
|
+
...record,
|
|
338
|
+
headCommit: repository.headCommit,
|
|
339
|
+
branch: repository.branch
|
|
340
|
+
} : record
|
|
341
|
+
)
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
...current,
|
|
346
|
+
repositories: [
|
|
347
|
+
...current.repositories,
|
|
348
|
+
{
|
|
349
|
+
id: createId(),
|
|
350
|
+
rootPath: repository.root,
|
|
351
|
+
commonDir: repository.commonDir,
|
|
352
|
+
displayName: path.basename(repository.root) || repository.root,
|
|
353
|
+
headCommit: repository.headCommit,
|
|
354
|
+
branch: repository.branch,
|
|
355
|
+
createdAtMs: now()
|
|
356
|
+
}
|
|
357
|
+
]
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
return snapshot(next);
|
|
361
|
+
}
|
|
362
|
+
async function provisionTask(request, prepared) {
|
|
363
|
+
await initialize();
|
|
364
|
+
const current = await store.read();
|
|
365
|
+
const repository = current.repositories.find((record) => record.id === request.repositoryId);
|
|
366
|
+
if (!repository) {
|
|
367
|
+
throw new WorkspaceOperationError(
|
|
368
|
+
"repository-not-found",
|
|
369
|
+
"The selected repository is no longer registered.",
|
|
370
|
+
404
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
const taskId = prepared?.taskId ?? createId();
|
|
374
|
+
if (current.tasks.some((task) => task.id === taskId)) {
|
|
375
|
+
throw new WorkspaceOperationError(
|
|
376
|
+
"workspace-unavailable",
|
|
377
|
+
"Webdesk could not reserve a unique task identity.",
|
|
378
|
+
503
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
const branch = `pita/${slugify(request.title)}-${taskId.slice(0, 8)}`;
|
|
382
|
+
const expectedWorktreePath = expectedTaskWorktreePath(repository.id, taskId);
|
|
383
|
+
await prepareTaskWorktreeParent(repository.id);
|
|
384
|
+
const worktreePath = expectedWorktreePath;
|
|
385
|
+
let plan;
|
|
386
|
+
try {
|
|
387
|
+
plan = await git.preflightTaskWorktree({
|
|
388
|
+
repositoryPath: repository.rootPath,
|
|
389
|
+
baseRef: request.baseRef,
|
|
390
|
+
branch,
|
|
391
|
+
worktreePath
|
|
392
|
+
});
|
|
393
|
+
} catch (error) {
|
|
394
|
+
throw gitError(error);
|
|
395
|
+
}
|
|
396
|
+
const provisioning = {
|
|
397
|
+
id: taskId,
|
|
398
|
+
repositoryId: repository.id,
|
|
399
|
+
title: request.title.trim(),
|
|
400
|
+
branch,
|
|
401
|
+
worktreePath,
|
|
402
|
+
baseCommit: plan.baseCommit,
|
|
403
|
+
createdAtMs: now(),
|
|
404
|
+
status: "provisioning"
|
|
405
|
+
};
|
|
406
|
+
await store.update((state) => ({
|
|
407
|
+
...state,
|
|
408
|
+
tasks: [...state.tasks, provisioning]
|
|
409
|
+
}));
|
|
410
|
+
try {
|
|
411
|
+
const created = await git.createTaskWorktree({
|
|
412
|
+
repositoryPath: repository.rootPath,
|
|
413
|
+
baseRef: plan.baseCommit,
|
|
414
|
+
branch,
|
|
415
|
+
worktreePath
|
|
416
|
+
});
|
|
417
|
+
if (created.worktree.root !== expectedWorktreePath) {
|
|
418
|
+
throw new GitWorktreeIncompleteError(
|
|
419
|
+
"created worktree escaped the daemon-owned canonical state directory; nothing was cleaned up automatically",
|
|
420
|
+
{
|
|
421
|
+
branch,
|
|
422
|
+
worktreePath,
|
|
423
|
+
baseCommit: plan.baseCommit,
|
|
424
|
+
branchExists: true,
|
|
425
|
+
targetRegisteredAsWorktree: true,
|
|
426
|
+
targetExistsOnDisk: true
|
|
427
|
+
}
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
provisioning.worktreePath = created.worktree.root;
|
|
431
|
+
} catch (error) {
|
|
432
|
+
try {
|
|
433
|
+
await store.update((state) => ({
|
|
434
|
+
...state,
|
|
435
|
+
tasks: state.tasks.map((task) => task.id === taskId ? failedTask(task, error) : task)
|
|
436
|
+
}));
|
|
437
|
+
} catch {
|
|
438
|
+
throw gitError(error);
|
|
439
|
+
}
|
|
440
|
+
throw gitError(error);
|
|
441
|
+
}
|
|
442
|
+
let piSessionFile;
|
|
443
|
+
let preparationError = null;
|
|
444
|
+
if (prepared !== void 0) {
|
|
445
|
+
try {
|
|
446
|
+
const result = await prepared.prepare({ ...provisioning, status: "provisioning" });
|
|
447
|
+
if (!path.isAbsolute(result.piSessionFile)) {
|
|
448
|
+
throw new Error("Pi returned a non-absolute child session path");
|
|
449
|
+
}
|
|
450
|
+
piSessionFile = path.normalize(result.piSessionFile);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
preparationError = error;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
let ready;
|
|
456
|
+
try {
|
|
457
|
+
ready = await store.update((state) => ({
|
|
458
|
+
...state,
|
|
459
|
+
tasks: state.tasks.map(
|
|
460
|
+
(task) => task.id === taskId ? {
|
|
461
|
+
...provisioning,
|
|
462
|
+
status: "ready",
|
|
463
|
+
...piSessionFile === void 0 ? {} : { piSessionFile }
|
|
464
|
+
} : task
|
|
465
|
+
)
|
|
466
|
+
}));
|
|
467
|
+
} catch (error) {
|
|
468
|
+
throw new WorkspaceOperationError(
|
|
469
|
+
"workspace-unavailable",
|
|
470
|
+
"The worktree was created and verified, but Webdesk could not record it as ready. Restart the daemon to reconcile the task safely.",
|
|
471
|
+
503,
|
|
472
|
+
{ cause: error }
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
snapshot: snapshot(workspaceStateSchema.parse(ready)),
|
|
477
|
+
taskId,
|
|
478
|
+
preparationError
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
async function createTask(request) {
|
|
482
|
+
return (await provisionTask(request)).snapshot;
|
|
483
|
+
}
|
|
484
|
+
async function createPreparedTask(request, options2) {
|
|
485
|
+
return provisionTask(request, options2);
|
|
486
|
+
}
|
|
487
|
+
async function archiveTask(taskId) {
|
|
488
|
+
await initialize();
|
|
489
|
+
const next = await store.update((state) => {
|
|
490
|
+
const taskIndex = state.tasks.findIndex((candidate) => candidate.id === taskId);
|
|
491
|
+
const task = state.tasks[taskIndex];
|
|
492
|
+
if (task === void 0) {
|
|
493
|
+
throw new WorkspaceOperationError(
|
|
494
|
+
"task-not-found",
|
|
495
|
+
"The selected task no longer exists.",
|
|
496
|
+
404
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
if (task.status === "archived") return state;
|
|
500
|
+
if (task.status !== "ready") {
|
|
501
|
+
throw new WorkspaceOperationError(
|
|
502
|
+
"task-not-ready",
|
|
503
|
+
`Only a ready task can be archived; this task is ${task.status}.`,
|
|
504
|
+
409
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
if (task.runtimeProcess !== void 0) {
|
|
508
|
+
throw new WorkspaceOperationError(
|
|
509
|
+
"task-recovery-required",
|
|
510
|
+
"Webdesk could not prove that the task runtime stopped cleanly, so the task was not archived.",
|
|
511
|
+
409
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
const archivedAtMs = now();
|
|
515
|
+
const { status: _status, runtimeProcess: _runtimeProcess, ...preserved } = task;
|
|
516
|
+
const archived = {
|
|
517
|
+
...preserved,
|
|
518
|
+
status: "archived",
|
|
519
|
+
archivedAtMs
|
|
520
|
+
};
|
|
521
|
+
return {
|
|
522
|
+
...state,
|
|
523
|
+
tasks: state.tasks.map((candidate, index) => index === taskIndex ? archived : candidate)
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
return snapshot(next);
|
|
527
|
+
}
|
|
528
|
+
async function restoreTask(taskId, archivedAtMs) {
|
|
529
|
+
await initialize();
|
|
530
|
+
const next = await store.update((state) => {
|
|
531
|
+
const taskIndex = state.tasks.findIndex((candidate) => candidate.id === taskId);
|
|
532
|
+
const task = state.tasks[taskIndex];
|
|
533
|
+
if (task === void 0) {
|
|
534
|
+
throw new WorkspaceOperationError(
|
|
535
|
+
"task-not-found",
|
|
536
|
+
"The selected task no longer exists.",
|
|
537
|
+
404
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
if (task.status === "ready") return state;
|
|
541
|
+
if (task.status !== "archived") {
|
|
542
|
+
throw new WorkspaceOperationError(
|
|
543
|
+
"task-not-ready",
|
|
544
|
+
`Only an archived task can be restored; this task is ${task.status}.`,
|
|
545
|
+
409
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
if (task.archivedAtMs !== archivedAtMs) {
|
|
549
|
+
throw new WorkspaceOperationError(
|
|
550
|
+
"task-busy",
|
|
551
|
+
"The archived task changed while restore was being verified. Retry from the refreshed workspace.",
|
|
552
|
+
409
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
const { status: _status, archivedAtMs: _archivedAtMs, ...preserved } = task;
|
|
556
|
+
const restored = { ...preserved, status: "ready" };
|
|
557
|
+
return {
|
|
558
|
+
...state,
|
|
559
|
+
tasks: state.tasks.map((candidate, index) => index === taskIndex ? restored : candidate)
|
|
560
|
+
};
|
|
561
|
+
});
|
|
562
|
+
return snapshot(next);
|
|
563
|
+
}
|
|
564
|
+
async function recordTaskSession(taskId, sessionFile) {
|
|
565
|
+
await initialize();
|
|
566
|
+
if (!path.isAbsolute(sessionFile)) {
|
|
567
|
+
throw new WorkspaceOperationError(
|
|
568
|
+
"workspace-unavailable",
|
|
569
|
+
"Pi reported a non-absolute session path, so Webdesk did not associate it with the task.",
|
|
570
|
+
503
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
const normalizedSessionFile = path.normalize(sessionFile);
|
|
574
|
+
await store.update((state) => {
|
|
575
|
+
const task = state.tasks.find((candidate) => candidate.id === taskId);
|
|
576
|
+
if (task === void 0) {
|
|
577
|
+
throw new WorkspaceOperationError(
|
|
578
|
+
"workspace-unavailable",
|
|
579
|
+
"The task disappeared before its Pi session could be recorded.",
|
|
580
|
+
503
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
if (task.status !== "ready") {
|
|
584
|
+
throw new WorkspaceOperationError(
|
|
585
|
+
"workspace-unavailable",
|
|
586
|
+
"Only a ready task can own a Pi session.",
|
|
587
|
+
503
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
if (task.piSessionFile !== void 0 && task.piSessionFile !== normalizedSessionFile) {
|
|
591
|
+
throw new WorkspaceOperationError(
|
|
592
|
+
"workspace-unavailable",
|
|
593
|
+
"Pi reported a different session file for a task that already owns one.",
|
|
594
|
+
503
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
if (task.piSessionFile === normalizedSessionFile) return state;
|
|
598
|
+
return {
|
|
599
|
+
...state,
|
|
600
|
+
tasks: state.tasks.map(
|
|
601
|
+
(candidate) => candidate.id === taskId ? { ...candidate, piSessionFile: normalizedSessionFile } : candidate
|
|
602
|
+
)
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
async function claimTaskRuntimeGeneration(taskId) {
|
|
607
|
+
await initialize();
|
|
608
|
+
let claimedGeneration = null;
|
|
609
|
+
await store.update((state) => {
|
|
610
|
+
const task = state.tasks.find((candidate) => candidate.id === taskId);
|
|
611
|
+
if (task === void 0) {
|
|
612
|
+
throw new WorkspaceOperationError(
|
|
613
|
+
"workspace-unavailable",
|
|
614
|
+
"The task disappeared before its runtime generation could be reserved.",
|
|
615
|
+
503
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
if (task.status !== "ready") {
|
|
619
|
+
throw new WorkspaceOperationError(
|
|
620
|
+
"workspace-unavailable",
|
|
621
|
+
"Only a ready task can reserve a runtime generation.",
|
|
622
|
+
503
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
if (task.runtimeProcess !== void 0) {
|
|
626
|
+
throw new WorkspaceOperationError(
|
|
627
|
+
"workspace-unavailable",
|
|
628
|
+
"A previously supervised Pi process must be recovered before starting another runtime.",
|
|
629
|
+
503
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
const nextGeneration = (task.runtimeGeneration ?? 0) + 1;
|
|
633
|
+
if (!Number.isSafeInteger(nextGeneration)) {
|
|
634
|
+
throw new WorkspaceOperationError(
|
|
635
|
+
"workspace-unavailable",
|
|
636
|
+
"The task runtime generation counter is exhausted.",
|
|
637
|
+
503
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
claimedGeneration = nextGeneration;
|
|
641
|
+
return {
|
|
642
|
+
...state,
|
|
643
|
+
tasks: state.tasks.map(
|
|
644
|
+
(candidate) => candidate.id === taskId ? { ...candidate, runtimeGeneration: nextGeneration } : candidate
|
|
645
|
+
)
|
|
646
|
+
};
|
|
647
|
+
});
|
|
648
|
+
if (claimedGeneration === null) {
|
|
649
|
+
throw new WorkspaceOperationError(
|
|
650
|
+
"workspace-unavailable",
|
|
651
|
+
"The runtime generation reservation did not complete.",
|
|
652
|
+
503
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
return claimedGeneration;
|
|
656
|
+
}
|
|
657
|
+
async function recordTaskRuntimeProcess(taskId, process) {
|
|
658
|
+
await initialize();
|
|
659
|
+
await store.update((state) => {
|
|
660
|
+
const task = state.tasks.find((candidate) => candidate.id === taskId);
|
|
661
|
+
if (task === void 0 || task.status !== "ready") {
|
|
662
|
+
throw new WorkspaceOperationError(
|
|
663
|
+
"workspace-unavailable",
|
|
664
|
+
"Only a ready task can own a supervised Pi process.",
|
|
665
|
+
503
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
if (task.runtimeGeneration !== process.runtimeGeneration) {
|
|
669
|
+
throw new WorkspaceOperationError(
|
|
670
|
+
"workspace-unavailable",
|
|
671
|
+
"The Pi process belongs to a stale runtime generation.",
|
|
672
|
+
503
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
if (task.runtimeProcess !== void 0) {
|
|
676
|
+
if (task.runtimeProcess.runtimeGeneration === process.runtimeGeneration && task.runtimeProcess.pid === process.pid && task.runtimeProcess.startFingerprint === process.startFingerprint) {
|
|
677
|
+
return state;
|
|
678
|
+
}
|
|
679
|
+
throw new WorkspaceOperationError(
|
|
680
|
+
"workspace-unavailable",
|
|
681
|
+
"Another supervised Pi process is still recorded for this task.",
|
|
682
|
+
503
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
...state,
|
|
687
|
+
tasks: state.tasks.map(
|
|
688
|
+
(candidate) => candidate.id === taskId ? { ...candidate, runtimeProcess: process } : candidate
|
|
689
|
+
)
|
|
690
|
+
};
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
async function clearTaskRuntimeProcess(taskId, generation, pid) {
|
|
694
|
+
await initialize();
|
|
695
|
+
await store.update((state) => {
|
|
696
|
+
const task = state.tasks.find((candidate) => candidate.id === taskId);
|
|
697
|
+
if (task === void 0 || task.status !== "ready" || task.runtimeProcess === void 0 || task.runtimeProcess.runtimeGeneration !== generation || task.runtimeProcess.pid !== pid) {
|
|
698
|
+
return state;
|
|
699
|
+
}
|
|
700
|
+
const { runtimeProcess: _removed, ...withoutProcess } = task;
|
|
701
|
+
return {
|
|
702
|
+
...state,
|
|
703
|
+
tasks: state.tasks.map(
|
|
704
|
+
(candidate) => candidate.id === taskId ? withoutProcess : candidate
|
|
705
|
+
)
|
|
706
|
+
};
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
async function recordTaskValidation(taskId, result) {
|
|
710
|
+
await initialize();
|
|
711
|
+
await store.update((state) => {
|
|
712
|
+
const task = state.tasks.find((candidate) => candidate.id === taskId);
|
|
713
|
+
if (task === void 0 || task.status !== "ready") {
|
|
714
|
+
throw new WorkspaceOperationError(
|
|
715
|
+
"workspace-unavailable",
|
|
716
|
+
"Only a ready task can record a validation result.",
|
|
717
|
+
503
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
...state,
|
|
722
|
+
tasks: state.tasks.map(
|
|
723
|
+
(candidate) => candidate.id === taskId ? { ...candidate, validation: result } : candidate
|
|
724
|
+
)
|
|
725
|
+
};
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
async function recordPendingMerge(repositoryId, record) {
|
|
729
|
+
await initialize();
|
|
730
|
+
await store.update((state) => {
|
|
731
|
+
const repository = state.repositories.find((candidate) => candidate.id === repositoryId);
|
|
732
|
+
if (repository === void 0) {
|
|
733
|
+
throw new WorkspaceOperationError(
|
|
734
|
+
"repository-not-found",
|
|
735
|
+
"The repository disappeared before its merge intent could be recorded.",
|
|
736
|
+
409
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
if (repository.pendingMerge !== void 0 && repository.pendingMerge.preflightId !== record.preflightId) {
|
|
740
|
+
throw new WorkspaceOperationError(
|
|
741
|
+
"workspace-unavailable",
|
|
742
|
+
"Another interrupted merge is still pending recovery for this repository.",
|
|
743
|
+
409
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
...state,
|
|
748
|
+
repositories: state.repositories.map(
|
|
749
|
+
(candidate) => candidate.id === repositoryId ? { ...candidate, pendingMerge: record } : candidate
|
|
750
|
+
)
|
|
751
|
+
};
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
async function clearPendingMerge(repositoryId, preflightId, resolvedHead) {
|
|
755
|
+
await initialize();
|
|
756
|
+
await store.update((state) => {
|
|
757
|
+
const repository = state.repositories.find((candidate) => candidate.id === repositoryId);
|
|
758
|
+
if (repository === void 0 || repository.pendingMerge === void 0 || repository.pendingMerge.preflightId !== preflightId) {
|
|
759
|
+
return state;
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
...state,
|
|
763
|
+
repositories: state.repositories.map((candidate) => {
|
|
764
|
+
if (candidate.id !== repositoryId) return candidate;
|
|
765
|
+
const { pendingMerge: _removed, ...withoutPending } = candidate;
|
|
766
|
+
return resolvedHead === void 0 ? withoutPending : { ...withoutPending, headCommit: resolvedHead };
|
|
767
|
+
})
|
|
768
|
+
};
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
return {
|
|
772
|
+
initialize,
|
|
773
|
+
async snapshot() {
|
|
774
|
+
await initialize();
|
|
775
|
+
return snapshot(await store.read());
|
|
776
|
+
},
|
|
777
|
+
registerRepository,
|
|
778
|
+
createTask,
|
|
779
|
+
createPreparedTask,
|
|
780
|
+
verifyTaskWorktree,
|
|
781
|
+
archiveTask,
|
|
782
|
+
restoreTask,
|
|
783
|
+
recordTaskSession,
|
|
784
|
+
claimTaskRuntimeGeneration,
|
|
785
|
+
recordTaskRuntimeProcess,
|
|
786
|
+
clearTaskRuntimeProcess,
|
|
787
|
+
recordTaskValidation,
|
|
788
|
+
recordPendingMerge,
|
|
789
|
+
clearPendingMerge
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
export {
|
|
793
|
+
WorkspaceOperationError,
|
|
794
|
+
createWorkspaceService
|
|
795
|
+
};
|