dsh-wsr-execution 0.2.1 → 0.2.3

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.
@@ -1,5 +1,7 @@
1
+ const USAGE = "Usage: /wsr list | create <selector> | recover [delivery-id] | status [delivery-id] | action finish. Usage: /wsr abandon [delivery-id]";
2
+
1
3
  function invalid() {
2
- throw new TypeError("WSR_COMMAND_INVALID");
4
+ throw Object.assign(new TypeError(`WSR_COMMAND_INVALID. ${USAGE}`), { code: "WSR_COMMAND_INVALID" });
3
5
  }
4
6
 
5
7
  function split(value) {
@@ -30,6 +32,10 @@ export function parseWsrCommand(value) {
30
32
  return Object.freeze({ operation: "status", deliveryId });
31
33
  }
32
34
  if (line === "action finish") return Object.freeze({ operation: "action-finish", ...(remainder === undefined ? {} : { remainder }) });
35
+ if (line === "abandon") {
36
+ if (remainder !== undefined) invalid();
37
+ return Object.freeze({ operation: "abandon" });
38
+ }
33
39
  if (line.startsWith("abandon ")) {
34
40
  const deliveryId = line.slice(8);
35
41
  if (deliveryId.length === 0 || deliveryId.includes(" ") || remainder !== undefined) invalid();
@@ -42,3 +48,13 @@ export function parseWsrCommand(value) {
42
48
  }
43
49
  return invalid();
44
50
  }
51
+
52
+ export function promptDiagnostic(operation, attachments = []) {
53
+ if (operation?.operation !== "create") return undefined;
54
+ const hasPrompt = typeof operation.remainder === "string" && operation.remainder.trim().length > 0;
55
+ if (hasPrompt || (Array.isArray(attachments) && attachments.length > 0)) return undefined;
56
+ return Object.freeze({
57
+ code: "TASK_PROMPT_REQUIRED",
58
+ message: "Add a Task instruction after the Workflow selector or attach a file.",
59
+ });
60
+ }
@@ -1,9 +1,10 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { readFile, readdir, realpath, stat, writeFile } from "node:fs/promises";
2
+ import { execFile } from "node:child_process";
3
+ import { lstat, readFile, readdir, realpath, stat, writeFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
 
5
6
  import { IntakeSessionBindingRepository } from "./binding-repository.js";
6
- import { parseWsrCommand } from "./command.js";
7
+ import { parseWsrCommand, promptDiagnostic } from "./command.js";
7
8
  import {
8
9
  createDshSessionControlPlaneReadModel,
9
10
  registerDeliveryControlPlaneGateway,
@@ -61,6 +62,72 @@ function error(code) {
61
62
  return Object.freeze({ kind: "ERROR", code, message: code });
62
63
  }
63
64
 
65
+ function gitInitFailure(cause) {
66
+ return Object.assign(new Error("GIT_INIT_FAILED", { cause }), { code: "GIT_INIT_FAILED" });
67
+ }
68
+
69
+ async function defaultGitInit(workspace) {
70
+ await runGit(workspace, ["init", "--quiet"]);
71
+ }
72
+
73
+ async function runGit(workspace, arguments_) {
74
+ return new Promise((accept, reject) => {
75
+ execFile("git", arguments_, { cwd: workspace }, (cause, stdout) => cause === null ? accept(stdout) : reject(cause));
76
+ });
77
+ }
78
+
79
+ async function hasGitHead(workspace) {
80
+ try {
81
+ await runGit(workspace, ["rev-parse", "--verify", "HEAD"]);
82
+ return true;
83
+ } catch (cause) {
84
+ if (cause?.code === 128) return false;
85
+ throw cause;
86
+ }
87
+ }
88
+
89
+ async function ensureGitHead(workspace) {
90
+ if (!await hasGitHead(workspace)) {
91
+ await runGit(workspace, ["add", "-A", "--", "."]);
92
+ await runGit(workspace, [
93
+ "-c", "user.name=WSR Workspace Initializer",
94
+ "-c", "user.email=wsr@localhost",
95
+ "-c", "commit.gpgSign=false",
96
+ "commit", "--quiet", "--allow-empty", "--no-verify", "-m", "Initialize WSR workspace",
97
+ ]);
98
+ }
99
+ await runGit(workspace, ["rev-parse", "--verify", "HEAD^{tree}"]);
100
+ }
101
+
102
+ async function hasGitMarker(workspace) {
103
+ try {
104
+ const marker = await lstat(path.join(workspace, ".git"));
105
+ return marker.isDirectory() || marker.isFile();
106
+ } catch (cause) {
107
+ if (cause?.code === "ENOENT") return false;
108
+ throw cause;
109
+ }
110
+ }
111
+
112
+ /** Establish the Git boundary only for an already authorized exact workspace. */
113
+ export async function ensureGitWorktree(workspace, initialize = defaultGitInit) {
114
+ try {
115
+ if (typeof workspace !== "string" || !path.isAbsolute(workspace) || typeof initialize !== "function") {
116
+ throw new TypeError("workspace must be an absolute path");
117
+ }
118
+ const canonical = await realpath(workspace);
119
+ if (canonical !== workspace || !(await stat(canonical)).isDirectory()) throw new TypeError("workspace is not canonical");
120
+ const initialized = !await hasGitMarker(canonical);
121
+ if (initialized) await initialize(canonical);
122
+ if (!await hasGitMarker(canonical)) throw new TypeError("git marker was not created");
123
+ await ensureGitHead(canonical);
124
+ return Object.freeze({ path: canonical, initialized });
125
+ } catch (cause) {
126
+ if (cause?.code === "GIT_INIT_FAILED") throw cause;
127
+ throw gitInitFailure(cause);
128
+ }
129
+ }
130
+
64
131
  function textOf(content) {
65
132
  if (!Array.isArray(content)) return "";
66
133
  return content.filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text).join("");
@@ -143,7 +210,7 @@ export function presentToDshSession(agent, presentation, createId = () => `cmd-w
143
210
  const commandId = createId();
144
211
  agent.session.append("command/run", {
145
212
  commandId,
146
- name: "wsr",
213
+ name: "wsr-presentation",
147
214
  source: { kind: "plugin", plugin: "workflow-execution" },
148
215
  });
149
216
  agent.session.append("command/done", { commandId, kind: presentation?.kind === "error" ? "error" : "success", text });
@@ -201,16 +268,36 @@ export async function createPluginRuntime(config, options = {}) {
201
268
  const factory = options.factory ?? new api.DefaultExecutionApplicationFactory();
202
269
  const application = await factory.create(admitted.configFile, dependencies);
203
270
  const control = options.control ?? api.getExecutionApplicationControl(application);
271
+ const ownerProjection = options.ownerProjection ?? api.getExecutionControlPlaneProjection(application);
204
272
  const bindingInventory = () => typeof control.bindingInventory === "function" ? control.bindingInventory() : control.list();
273
+ const archiveTerminal = async (sessionKey, correlation, deliveryId) => {
274
+ const snapshot = await ownerProjection.snapshot();
275
+ const matches = snapshot.deliveries.filter((delivery) => delivery.lifecycle === "TERMINAL"
276
+ && delivery.navigation?.sessionCorrelation === correlation
277
+ && (deliveryId === undefined || delivery.deliveryId === deliveryId));
278
+ if (matches.length !== 1) {
279
+ throw Object.assign(new Error("INTAKE_BINDING_INVARIANT_VIOLATION"), { code: "INTAKE_BINDING_INVARIANT_VIOLATION" });
280
+ }
281
+ await bindings.archiveTerminal(sessionKey, matches[0]);
282
+ };
205
283
  try {
206
284
  await application.start();
207
285
  const inventory = await bindingInventory();
208
286
  await bindings.start(inventory);
287
+ const snapshot = await ownerProjection.snapshot();
209
288
  for (const binding of await bindings.list()) {
210
289
  const sameDelivery = inventory.filter((item) => item.deliveryId === binding.deliveryId || item.worktree === binding.worktree);
211
290
  const matches = sameDelivery.filter((item) => item.deliveryId === binding.deliveryId && item.worktree === binding.worktree
212
291
  && item.deliveryBindingIdentity === binding.deliveryBindingIdentity);
213
292
  if (matches.length === 0 && sameDelivery.length === 0) {
293
+ const terminal = snapshot.deliveries.filter((delivery) => delivery.lifecycle === "TERMINAL"
294
+ && delivery.deliveryId === binding.deliveryId && delivery.worktree === binding.worktree
295
+ && delivery.deliveryBindingIdentity === binding.deliveryBindingIdentity
296
+ && delivery.navigation?.sessionCorrelation === binding.correlation);
297
+ if (terminal.length === 1) {
298
+ await bindings.archiveTerminal(binding.sessionKey, terminal[0]);
299
+ continue;
300
+ }
214
301
  await bindings.detach(binding.deliveryId);
215
302
  continue;
216
303
  }
@@ -258,6 +345,19 @@ export async function createPluginRuntime(config, options = {}) {
258
345
  return promise;
259
346
  }
260
347
 
348
+ async function awaitRegistrationOrResult(execution, correlation) {
349
+ const result = execution.then((value) => Object.freeze({ kind: "result", result: value }));
350
+ while (true) {
351
+ const first = await Promise.race([
352
+ result,
353
+ control.waitForDelivery(correlation, options.deliveryRegistrationTimeoutMs ?? 10_000)
354
+ .then((delivery) => Object.freeze({ kind: "delivery", delivery })),
355
+ ]);
356
+ if (first.kind === "result" || first.delivery !== undefined) return first;
357
+ await new Promise((resolve) => setTimeout(resolve, 0));
358
+ }
359
+ }
360
+
261
361
  async function invokeForSession(input) {
262
362
  if (!accepting) return error("APPLICATION_CLOSING");
263
363
  const candidateBinding = await bindings.bySession(input.sessionKey);
@@ -288,24 +388,19 @@ export async function createPluginRuntime(config, options = {}) {
288
388
  if (candidateBinding !== undefined) return error("SESSION_INTAKE_BOUND");
289
389
  const authorization = await conversationAuthorization();
290
390
  if (authorization === undefined) return error("DSH_INTAKE_WORKSPACE_UNAUTHORIZED");
391
+ try { await (options.ensureGitWorktree ?? ensureGitWorktree)(authorization.path); }
392
+ catch { return error("GIT_INIT_FAILED"); }
291
393
  sessionByCorrelation.set(correlation, input.sessionKey);
292
394
  const execution = track(service.invoke(Object.freeze({ operation: "create", selector: operation.selector, worktree: authorization.path, directive: operation.directive, turn, correlation }), authorization));
293
- const first = await Promise.race([
294
- execution.then((result) => Object.freeze({ kind: "result", result })),
295
- control.waitForDelivery(correlation, options.deliveryRegistrationTimeoutMs ?? 10_000)
296
- .then((delivery) => Object.freeze({ kind: "delivery", delivery })),
297
- ]);
395
+ const first = await awaitRegistrationOrResult(execution, correlation);
298
396
  if (first.kind === "result") {
397
+ if (first.result.kind === "TERMINAL") await archiveTerminal(input.sessionKey, correlation, first.result.deliveryId);
299
398
  if (first.result.kind === "ERROR" || first.result.kind === "TERMINAL" || first.result.kind === "RECOVERY") sessionByCorrelation.delete(correlation);
300
399
  return first.result;
301
400
  }
302
401
  const delivery = first.delivery;
303
- if (delivery === undefined) {
304
- const result = await execution;
305
- if (result.kind === "ERROR" || result.kind === "TERMINAL") sessionByCorrelation.delete(correlation);
306
- return result;
307
- }
308
402
  await bindings.claim(Object.freeze({ sessionKey: input.sessionKey, correlation, deliveryId: delivery.deliveryId, worktree: delivery.worktree, deliveryBindingIdentity: delivery.deliveryBindingIdentity }));
403
+ control.attach(delivery.deliveryId, correlation);
309
404
  void track(execution.then(async (result) => {
310
405
  try { await options.present?.(Object.freeze({
311
406
  sessionKey: input.sessionKey,
@@ -313,7 +408,8 @@ export async function createPluginRuntime(config, options = {}) {
313
408
  })); }
314
409
  catch { /* presentation is not Delivery control */ }
315
410
  if (result.kind === "TERMINAL" || result.kind === "ERROR") {
316
- await bindings.detach(delivery.deliveryId);
411
+ if (result.kind === "TERMINAL") await archiveTerminal(input.sessionKey, correlation, delivery.deliveryId);
412
+ else await bindings.detach(delivery.deliveryId);
317
413
  sessionByCorrelation.delete(correlation);
318
414
  }
319
415
  })).catch(() => undefined);
@@ -339,6 +435,7 @@ export async function createPluginRuntime(config, options = {}) {
339
435
  const recovered = (await bindingInventory()).filter((item) => item.deliveryId === result.deliveryId && item.worktree === result.worktree);
340
436
  if (recovered.length !== 1) return error("INTAKE_BINDING_INVARIANT_VIOLATION");
341
437
  await bindings.claim(Object.freeze({ sessionKey: input.sessionKey, correlation, deliveryId: result.deliveryId, worktree: result.worktree, deliveryBindingIdentity: recovered[0].deliveryBindingIdentity }));
438
+ control.attach(result.deliveryId, correlation);
342
439
  sessionByCorrelation.set(correlation, input.sessionKey);
343
440
  }
344
441
  return result;
@@ -354,10 +451,12 @@ export async function createPluginRuntime(config, options = {}) {
354
451
  if (existing === undefined) return error("DELIVERY_UNKNOWN");
355
452
  return service.invoke(Object.freeze({ operation: "action-finish", ...(operation.remainder === undefined && attachments.length === 0 ? {} : { turn: Object.freeze({ text: operation.remainder ?? "", attachments }) }), correlation: existing.correlation }));
356
453
  }
357
- const result = await service.invoke(Object.freeze({ operation: "abandon", deliveryId: operation.deliveryId, correlation }));
454
+ const deliveryId = operation.deliveryId ?? existing?.deliveryId;
455
+ if (deliveryId === undefined) return error("WSR_COMMAND_INVALID");
456
+ const result = await service.invoke(Object.freeze({ operation: "abandon", deliveryId, correlation }));
358
457
  if (result.kind === "TERMINAL") {
359
- const detached = await bindings.byDelivery(operation.deliveryId);
360
- await bindings.detach(operation.deliveryId);
458
+ const detached = await bindings.byDelivery(deliveryId);
459
+ if (detached !== undefined) await archiveTerminal(detached.sessionKey, detached.correlation, deliveryId);
361
460
  if (detached !== undefined) sessionByCorrelation.delete(detached.correlation);
362
461
  }
363
462
  return result;
@@ -385,7 +484,7 @@ export async function createPluginRuntime(config, options = {}) {
385
484
  return closePromise;
386
485
  }
387
486
 
388
- return Object.freeze({ application, service, control, bindings, invokeForSession, answerForSession, close });
487
+ return Object.freeze({ application, service, control, ownerProjection, bindings, invokeForSession, answerForSession, close });
389
488
  }
390
489
 
391
490
  function commandTurn(rawInput) {
@@ -394,7 +493,7 @@ function commandTurn(rawInput) {
394
493
  }
395
494
 
396
495
  export async function recordWsrCommandInput(agent, rawInput, attachments = [], createId = () => `message-workflow-execution-${randomUUID()}`) {
397
- if (agent === null || typeof agent !== "object" || typeof agent.followup !== "function" || typeof agent.whenIdle !== "function"
496
+ if (agent === null || typeof agent !== "object" || typeof agent.session?.append !== "function"
398
497
  || typeof rawInput !== "string" || !Array.isArray(attachments) || typeof createId !== "function") {
399
498
  throw new TypeError("DSH_INTAKE_USER_INPUT_INVALID");
400
499
  }
@@ -407,8 +506,7 @@ export async function recordWsrCommandInput(agent, rawInput, attachments = [], c
407
506
  ...attachments,
408
507
  ]),
409
508
  });
410
- agent.followup(message);
411
- await agent.whenIdle();
509
+ agent.session.append("user/message", message, { surfaceOp: "append" });
412
510
  return message;
413
511
  }
414
512
 
@@ -418,7 +516,8 @@ export function mapIntakeToolOperation(args) {
418
516
  || Object.keys(args).some((key) => !["operation", "selector", "deliveryId"].includes(key))
419
517
  || !operationNames.includes(args.operation)
420
518
  || (args.operation === "create") !== (typeof args.selector === "string" && args.selector.length > 0)
421
- || (args.operation === "abandon" && (typeof args.deliveryId !== "string" || args.deliveryId.length === 0))
519
+ || (args.operation === "abandon" && args.deliveryId !== undefined
520
+ && (typeof args.deliveryId !== "string" || args.deliveryId.length === 0))
422
521
  || (!["recover", "status", "abandon"].includes(args.operation) && args.deliveryId !== undefined)) {
423
522
  throw new TypeError("INTAKE_OPERATION_INVALID");
424
523
  }
@@ -430,11 +529,9 @@ export async function apply(ctx, config) {
430
529
  const runtime = await createPluginRuntime(config, { present: (value) => presentationRouter.present(value),
431
530
  sessionAvailable: (sessionKey) => ctx.agents.get(sessionKey) !== undefined,
432
531
  resolveConversationWorkspace: async (agent) => resolveConversationWorkspace(ctx, agent) });
433
- const executionApi = await import("wsr-execution");
434
- const ownerProjection = executionApi.getExecutionControlPlaneProjection(runtime.application);
435
532
  await registerDeliveryControlPlaneGateway(
436
533
  ctx,
437
- createDshSessionControlPlaneReadModel(ownerProjection, runtime.bindings),
534
+ createDshSessionControlPlaneReadModel(runtime.ownerProjection, runtime.bindings),
438
535
  );
439
536
  const active = new Set();
440
537
  const attachmentStore = ctx.attachments;
@@ -442,43 +539,44 @@ export async function apply(ctx, config) {
442
539
  const command = ctx.commands.register({
443
540
  name: "wsr",
444
541
  description: "Create, list, recover, inspect, finish, or abandon a Workflow Delivery",
445
- input: { hint: "list | create <selector> | recover [delivery-id] | status [delivery-id] | action finish | abandon <delivery-id>", images: true },
542
+ input: { hint: "list | create <selector> | recover [delivery-id] | status [delivery-id] | action finish | abandon [delivery-id]", images: true },
446
543
  recordInput: true,
447
544
  async handler(invocation) {
448
545
  return run((async () => {
449
- let query = false;
450
546
  try {
547
+ await recordWsrCommandInput(invocation.agent, invocation.rawInput, invocation.attachments);
451
548
  const operation = parseWsrCommand(invocation.rawInput);
452
- if (["create", "recover"].includes(operation.operation)) {
453
- presentationRouter.retain(String(invocation.agent.id), invocation.agent);
454
- }
455
- query = operation.operation === "list" || operation.operation === "status";
456
549
  const { createIntakePresentation, presentationForIntakeResult, serializeIntakePresentation } = await import("wsr-execution");
457
- if (!query) {
458
- await recordWsrCommandInput(invocation.agent, invocation.rawInput, invocation.attachments);
459
- presentToDshSession(invocation.agent, createIntakePresentation(
460
- String(invocation.commandId), "command-accepted", {},
461
- ));
550
+ const complete = (presentation, kind) => {
551
+ presentToDshSession(invocation.agent, presentation);
552
+ return { kind, text: serializeIntakePresentation(presentation, 4096) };
553
+ };
554
+ const diagnostic = promptDiagnostic(operation, invocation.attachments);
555
+ if (diagnostic !== undefined) {
556
+ const presentation = createIntakePresentation(`presentation-${randomUUID()}`, "error", diagnostic);
557
+ return complete(presentation, "error");
462
558
  }
463
559
  if (invocation.attachments.length > 0 && !["create", "action-finish"].includes(operation.operation)) {
464
560
  const presentation = createIntakePresentation(
465
561
  `presentation-${randomUUID()}`, "error", { code: "WSR_COMMAND_INVALID", message: "WSR_COMMAND_INVALID" },
466
562
  );
467
- if (!query) presentToDshSession(invocation.agent, presentation);
468
- return { kind: "error", text: serializeIntakePresentation(presentation, 4096) };
563
+ return complete(presentation, "error");
564
+ }
565
+ if (["create", "recover"].includes(operation.operation)) {
566
+ presentationRouter.retain(String(invocation.agent.id), invocation.agent);
469
567
  }
470
568
  const result = await runtime.invokeForSession({ sessionKey: String(invocation.agent.id), agent: invocation.agent, operation, turnText: commandTurn(invocation.rawInput), images: invocation.attachments, attachmentStore, signal: invocation.signal });
471
569
  if (["create", "recover"].includes(operation.operation) && !["START_UNCERTAIN", "RECOVERY"].includes(result.kind)) {
472
570
  presentationRouter.release(String(invocation.agent.id));
473
571
  }
474
572
  const presentation = presentationForDshOperation({ createIntakePresentation, presentationForIntakeResult }, `presentation-${randomUUID()}`, operation, result, 4096);
475
- if (!query) presentToDshSession(invocation.agent, presentation);
476
- return { kind: result.kind === "ERROR" ? "error" : "success", text: serializeIntakePresentation(presentation, 4096) };
573
+ return complete(presentation, result.kind === "ERROR" ? "error" : "success");
477
574
  } catch (cause) {
478
575
  const { createIntakePresentation, serializeIntakePresentation } = await import("wsr-execution");
479
576
  const code = typeof cause?.code === "string" ? cause.code : "DSH_INTAKE_FAILED";
480
- const presentation = createIntakePresentation(`presentation-${randomUUID()}`, "error", { code, message: code });
481
- if (!query) presentToDshSession(invocation.agent, presentation);
577
+ const message = code === "WSR_COMMAND_INVALID" && typeof cause?.message === "string" ? cause.message : code;
578
+ const presentation = createIntakePresentation(`presentation-${randomUUID()}`, "error", { code, message });
579
+ presentToDshSession(invocation.agent, presentation);
482
580
  return { kind: "error", text: serializeIntakePresentation(presentation, 4096) };
483
581
  }
484
582
  })());