git-workspace-service 0.1.0 → 0.3.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/dist/index.js CHANGED
@@ -258,6 +258,22 @@ var WorkspaceService = class {
258
258
  * Provision a new workspace for a task
259
259
  */
260
260
  async provision(config) {
261
+ const strategy = config.strategy || "clone";
262
+ if (strategy === "worktree") {
263
+ if (!config.parentWorkspace) {
264
+ throw new Error('parentWorkspace is required when strategy is "worktree"');
265
+ }
266
+ const parent = this.workspaces.get(config.parentWorkspace);
267
+ if (!parent) {
268
+ throw new Error(`Parent workspace not found: ${config.parentWorkspace}`);
269
+ }
270
+ if (parent.strategy !== "clone") {
271
+ throw new Error("Parent workspace must be a clone, not a worktree");
272
+ }
273
+ if (parent.repo !== config.repo) {
274
+ throw new Error("Worktree must be for the same repository as parent");
275
+ }
276
+ }
261
277
  const workspaceId = randomUUID();
262
278
  this.log(
263
279
  "info",
@@ -265,7 +281,8 @@ var WorkspaceService = class {
265
281
  workspaceId,
266
282
  repo: config.repo,
267
283
  executionId: config.execution.id,
268
- role: config.task.role
284
+ role: config.task.role,
285
+ strategy
269
286
  },
270
287
  "Provisioning workspace"
271
288
  );
@@ -276,26 +293,31 @@ var WorkspaceService = class {
276
293
  timestamp: /* @__PURE__ */ new Date()
277
294
  });
278
295
  const workspacePath = path.join(this.baseDir, workspaceId);
279
- await fs3.mkdir(workspacePath, { recursive: true });
280
- const credential = await this.credentialService.getCredentials({
281
- repo: config.repo,
282
- access: "write",
283
- context: {
296
+ let credential;
297
+ if (strategy === "worktree" && config.parentWorkspace) {
298
+ const parent = this.workspaces.get(config.parentWorkspace);
299
+ credential = parent.credential;
300
+ } else {
301
+ await fs3.mkdir(workspacePath, { recursive: true });
302
+ credential = await this.credentialService.getCredentials({
303
+ repo: config.repo,
304
+ access: "write",
305
+ context: {
306
+ executionId: config.execution.id,
307
+ taskId: config.task.id,
308
+ userId: config.user?.id,
309
+ reason: `Workspace for ${config.task.role} in ${config.execution.patternName}`
310
+ },
311
+ userProvided: config.userCredentials
312
+ });
313
+ await this.emitEvent({
314
+ type: "credential:granted",
315
+ workspaceId,
316
+ credentialId: credential.id,
284
317
  executionId: config.execution.id,
285
- taskId: config.task.id,
286
- userId: config.user?.id,
287
- reason: `Workspace for ${config.task.role} in ${config.execution.patternName}`
288
- },
289
- // Pass user-provided credentials if available
290
- userProvided: config.userCredentials
291
- });
292
- await this.emitEvent({
293
- type: "credential:granted",
294
- workspaceId,
295
- credentialId: credential.id,
296
- executionId: config.execution.id,
297
- timestamp: /* @__PURE__ */ new Date()
298
- });
318
+ timestamp: /* @__PURE__ */ new Date()
319
+ });
320
+ }
299
321
  const branchInfo = createBranchInfo(
300
322
  {
301
323
  executionId: config.execution.id,
@@ -312,21 +334,52 @@ var WorkspaceService = class {
312
334
  branch: branchInfo,
313
335
  credential,
314
336
  provisionedAt: /* @__PURE__ */ new Date(),
315
- status: "provisioning"
337
+ status: "provisioning",
338
+ strategy,
339
+ parentWorkspaceId: config.parentWorkspace,
340
+ onComplete: config.onComplete,
341
+ progress: {
342
+ phase: "initializing",
343
+ message: "Initializing workspace",
344
+ updatedAt: /* @__PURE__ */ new Date()
345
+ }
316
346
  };
317
347
  this.workspaces.set(workspaceId, workspace);
318
348
  try {
319
- await this.cloneRepo(workspace, credential.token);
320
- await this.createBranch(workspace);
349
+ if (strategy === "clone") {
350
+ this.updateProgress(workspace, "cloning", "Cloning repository");
351
+ await this.cloneRepo(workspace, credential.token);
352
+ this.updateProgress(workspace, "creating_branch", "Creating branch");
353
+ await this.createBranch(workspace);
354
+ } else {
355
+ const parent = this.workspaces.get(config.parentWorkspace);
356
+ await this.addWorktreeFromParent(parent, workspace);
357
+ if (!parent.worktreeIds) {
358
+ parent.worktreeIds = [];
359
+ }
360
+ parent.worktreeIds.push(workspaceId);
361
+ this.workspaces.set(parent.id, parent);
362
+ await this.emitEvent({
363
+ type: "worktree:added",
364
+ workspaceId,
365
+ executionId: config.execution.id,
366
+ timestamp: /* @__PURE__ */ new Date(),
367
+ data: { parentWorkspaceId: parent.id }
368
+ });
369
+ }
370
+ this.updateProgress(workspace, "configuring", "Configuring git");
321
371
  await this.configureGit(workspace);
322
372
  workspace.status = "ready";
373
+ this.updateProgress(workspace, "ready", "Workspace ready");
323
374
  this.workspaces.set(workspaceId, workspace);
375
+ await this.executeCompletionHook(workspace, "success");
324
376
  this.log(
325
377
  "info",
326
378
  {
327
379
  workspaceId,
328
380
  path: workspacePath,
329
- branch: branchInfo.name
381
+ branch: branchInfo.name,
382
+ strategy
330
383
  },
331
384
  "Workspace provisioned"
332
385
  );
@@ -339,8 +392,9 @@ var WorkspaceService = class {
339
392
  return workspace;
340
393
  } catch (error) {
341
394
  workspace.status = "error";
342
- this.workspaces.set(workspaceId, workspace);
343
395
  const errorMessage = error instanceof Error ? error.message : String(error);
396
+ this.updateProgress(workspace, "error", errorMessage);
397
+ this.workspaces.set(workspaceId, workspace);
344
398
  this.log("error", { workspaceId, error: errorMessage }, "Failed to provision workspace");
345
399
  await this.emitEvent({
346
400
  type: "workspace:error",
@@ -349,9 +403,54 @@ var WorkspaceService = class {
349
403
  timestamp: /* @__PURE__ */ new Date(),
350
404
  error: errorMessage
351
405
  });
406
+ await this.executeCompletionHook(workspace, "error");
352
407
  throw error;
353
408
  }
354
409
  }
410
+ /**
411
+ * Add a worktree to an existing clone workspace (convenience method)
412
+ */
413
+ async addWorktree(parentWorkspaceId, options) {
414
+ const parent = this.workspaces.get(parentWorkspaceId);
415
+ if (!parent) {
416
+ throw new Error(`Parent workspace not found: ${parentWorkspaceId}`);
417
+ }
418
+ return this.provision({
419
+ repo: parent.repo,
420
+ strategy: "worktree",
421
+ parentWorkspace: parentWorkspaceId,
422
+ branchStrategy: "feature_branch",
423
+ baseBranch: options.branch,
424
+ execution: options.execution,
425
+ task: options.task
426
+ });
427
+ }
428
+ /**
429
+ * List all worktrees for a parent workspace
430
+ */
431
+ listWorktrees(parentWorkspaceId) {
432
+ const parent = this.workspaces.get(parentWorkspaceId);
433
+ if (!parent) {
434
+ return [];
435
+ }
436
+ if (!parent.worktreeIds || parent.worktreeIds.length === 0) {
437
+ return [];
438
+ }
439
+ return parent.worktreeIds.map((id) => this.workspaces.get(id)).filter((w) => w !== void 0);
440
+ }
441
+ /**
442
+ * Remove a worktree (alias for cleanup with worktree-specific handling)
443
+ */
444
+ async removeWorktree(workspaceId) {
445
+ const workspace = this.workspaces.get(workspaceId);
446
+ if (!workspace) {
447
+ return;
448
+ }
449
+ if (workspace.strategy !== "worktree") {
450
+ throw new Error("Workspace is not a worktree. Use cleanup() instead.");
451
+ }
452
+ await this.cleanup(workspaceId);
453
+ }
355
454
  /**
356
455
  * Finalize a workspace (push, create PR, cleanup)
357
456
  */
@@ -431,21 +530,55 @@ var WorkspaceService = class {
431
530
  if (!workspace) {
432
531
  return;
433
532
  }
434
- this.log("info", { workspaceId }, "Cleaning up workspace");
533
+ this.log("info", { workspaceId, strategy: workspace.strategy }, "Cleaning up workspace");
534
+ if (workspace.strategy === "clone" && workspace.worktreeIds?.length) {
535
+ this.log(
536
+ "info",
537
+ { workspaceId, worktreeCount: workspace.worktreeIds.length },
538
+ "Cleaning up child worktrees first"
539
+ );
540
+ for (const worktreeId of workspace.worktreeIds) {
541
+ await this.cleanup(worktreeId);
542
+ }
543
+ }
435
544
  try {
436
545
  await cleanupCredentialFiles(workspace.path);
437
546
  } catch (error) {
438
547
  const errorMessage = error instanceof Error ? error.message : String(error);
439
548
  this.log("warn", { workspaceId, error: errorMessage }, "Failed to clean up credential files");
440
549
  }
441
- await this.credentialService.revokeCredential(workspace.credential.id);
442
- await this.emitEvent({
443
- type: "credential:revoked",
444
- workspaceId,
445
- credentialId: workspace.credential.id,
446
- executionId: workspace.branch.executionId,
447
- timestamp: /* @__PURE__ */ new Date()
448
- });
550
+ if (workspace.strategy === "worktree" && workspace.parentWorkspaceId) {
551
+ const parent = this.workspaces.get(workspace.parentWorkspaceId);
552
+ if (parent) {
553
+ try {
554
+ await this.execInDir(parent.path, `git worktree remove "${workspace.path}" --force`);
555
+ } catch (error) {
556
+ const errorMessage = error instanceof Error ? error.message : String(error);
557
+ this.log("warn", { workspaceId, error: errorMessage }, "Failed to remove worktree via git");
558
+ }
559
+ if (parent.worktreeIds) {
560
+ parent.worktreeIds = parent.worktreeIds.filter((id) => id !== workspaceId);
561
+ this.workspaces.set(parent.id, parent);
562
+ }
563
+ await this.emitEvent({
564
+ type: "worktree:removed",
565
+ workspaceId,
566
+ executionId: workspace.branch.executionId,
567
+ timestamp: /* @__PURE__ */ new Date(),
568
+ data: { parentWorkspaceId: parent.id }
569
+ });
570
+ }
571
+ }
572
+ if (workspace.strategy === "clone") {
573
+ await this.credentialService.revokeCredential(workspace.credential.id);
574
+ await this.emitEvent({
575
+ type: "credential:revoked",
576
+ workspaceId,
577
+ credentialId: workspace.credential.id,
578
+ executionId: workspace.branch.executionId,
579
+ timestamp: /* @__PURE__ */ new Date()
580
+ });
581
+ }
449
582
  try {
450
583
  await fs3.rm(workspace.path, { recursive: true, force: true });
451
584
  } catch (error) {
@@ -482,6 +615,16 @@ var WorkspaceService = class {
482
615
  async createBranch(workspace) {
483
616
  await this.execInDir(workspace.path, `git checkout -b ${workspace.branch.name}`);
484
617
  }
618
+ async addWorktreeFromParent(parent, workspace) {
619
+ try {
620
+ await this.execInDir(parent.path, `git fetch origin ${workspace.branch.baseBranch}`);
621
+ } catch {
622
+ }
623
+ await this.execInDir(
624
+ parent.path,
625
+ `git worktree add -b ${workspace.branch.name} "${workspace.path}" origin/${workspace.branch.baseBranch}`
626
+ );
627
+ }
485
628
  async configureGit(workspace) {
486
629
  await this.execInDir(workspace.path, 'git config user.name "Workspace Agent"');
487
630
  await this.execInDir(workspace.path, 'git config user.email "agent@workspace.local"');
@@ -597,6 +740,72 @@ var WorkspaceService = class {
597
740
  }
598
741
  }
599
742
  }
743
+ /**
744
+ * Update workspace progress
745
+ */
746
+ updateProgress(workspace, phase, message) {
747
+ workspace.progress = {
748
+ phase,
749
+ message,
750
+ updatedAt: /* @__PURE__ */ new Date()
751
+ };
752
+ this.workspaces.set(workspace.id, workspace);
753
+ this.log(
754
+ "debug",
755
+ { workspaceId: workspace.id, phase, message },
756
+ "Progress updated"
757
+ );
758
+ }
759
+ /**
760
+ * Execute completion hook if configured
761
+ */
762
+ async executeCompletionHook(workspace, status) {
763
+ const hook = workspace.onComplete;
764
+ if (!hook) return;
765
+ if (status === "error" && hook.runOnError === false) {
766
+ return;
767
+ }
768
+ const env = {
769
+ ...process.env,
770
+ WORKSPACE_ID: workspace.id,
771
+ REPO: workspace.repo,
772
+ BRANCH: workspace.branch.name,
773
+ STATUS: status,
774
+ WORKSPACE_PATH: workspace.path
775
+ };
776
+ if (hook.command) {
777
+ try {
778
+ this.log("info", { workspaceId: workspace.id, command: hook.command }, "Executing completion hook command");
779
+ await execAsync(hook.command, { env });
780
+ } catch (error) {
781
+ const errorMessage = error instanceof Error ? error.message : String(error);
782
+ this.log("warn", { workspaceId: workspace.id, error: errorMessage }, "Completion hook command failed");
783
+ }
784
+ }
785
+ if (hook.webhook) {
786
+ try {
787
+ this.log("info", { workspaceId: workspace.id, webhook: hook.webhook }, "Calling completion webhook");
788
+ const payload = {
789
+ workspaceId: workspace.id,
790
+ repo: workspace.repo,
791
+ branch: workspace.branch.name,
792
+ status,
793
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
794
+ };
795
+ await fetch(hook.webhook, {
796
+ method: "POST",
797
+ headers: {
798
+ "Content-Type": "application/json",
799
+ ...hook.webhookHeaders
800
+ },
801
+ body: JSON.stringify(payload)
802
+ });
803
+ } catch (error) {
804
+ const errorMessage = error instanceof Error ? error.message : String(error);
805
+ this.log("warn", { workspaceId: workspace.id, error: errorMessage }, "Completion webhook failed");
806
+ }
807
+ }
808
+ }
600
809
  };
601
810
 
602
811
  // src/oauth/device-flow.ts