dsh-wsr-execution 0.2.1 → 0.2.2

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,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;
@@ -357,7 +454,7 @@ export async function createPluginRuntime(config, options = {}) {
357
454
  const result = await service.invoke(Object.freeze({ operation: "abandon", deliveryId: operation.deliveryId, correlation }));
358
455
  if (result.kind === "TERMINAL") {
359
456
  const detached = await bindings.byDelivery(operation.deliveryId);
360
- await bindings.detach(operation.deliveryId);
457
+ if (detached !== undefined) await archiveTerminal(detached.sessionKey, detached.correlation, operation.deliveryId);
361
458
  if (detached !== undefined) sessionByCorrelation.delete(detached.correlation);
362
459
  }
363
460
  return result;
@@ -385,7 +482,7 @@ export async function createPluginRuntime(config, options = {}) {
385
482
  return closePromise;
386
483
  }
387
484
 
388
- return Object.freeze({ application, service, control, bindings, invokeForSession, answerForSession, close });
485
+ return Object.freeze({ application, service, control, ownerProjection, bindings, invokeForSession, answerForSession, close });
389
486
  }
390
487
 
391
488
  function commandTurn(rawInput) {
@@ -394,7 +491,7 @@ function commandTurn(rawInput) {
394
491
  }
395
492
 
396
493
  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"
494
+ if (agent === null || typeof agent !== "object" || typeof agent.session?.append !== "function"
398
495
  || typeof rawInput !== "string" || !Array.isArray(attachments) || typeof createId !== "function") {
399
496
  throw new TypeError("DSH_INTAKE_USER_INPUT_INVALID");
400
497
  }
@@ -407,8 +504,7 @@ export async function recordWsrCommandInput(agent, rawInput, attachments = [], c
407
504
  ...attachments,
408
505
  ]),
409
506
  });
410
- agent.followup(message);
411
- await agent.whenIdle();
507
+ agent.session.append("user/message", message, { surfaceOp: "append" });
412
508
  return message;
413
509
  }
414
510
 
@@ -430,11 +526,9 @@ export async function apply(ctx, config) {
430
526
  const runtime = await createPluginRuntime(config, { present: (value) => presentationRouter.present(value),
431
527
  sessionAvailable: (sessionKey) => ctx.agents.get(sessionKey) !== undefined,
432
528
  resolveConversationWorkspace: async (agent) => resolveConversationWorkspace(ctx, agent) });
433
- const executionApi = await import("wsr-execution");
434
- const ownerProjection = executionApi.getExecutionControlPlaneProjection(runtime.application);
435
529
  await registerDeliveryControlPlaneGateway(
436
530
  ctx,
437
- createDshSessionControlPlaneReadModel(ownerProjection, runtime.bindings),
531
+ createDshSessionControlPlaneReadModel(runtime.ownerProjection, runtime.bindings),
438
532
  );
439
533
  const active = new Set();
440
534
  const attachmentStore = ctx.attachments;
@@ -446,39 +540,39 @@ export async function apply(ctx, config) {
446
540
  recordInput: true,
447
541
  async handler(invocation) {
448
542
  return run((async () => {
449
- let query = false;
450
543
  try {
544
+ await recordWsrCommandInput(invocation.agent, invocation.rawInput, invocation.attachments);
451
545
  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
546
  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
- ));
547
+ const complete = (presentation, kind) => {
548
+ presentToDshSession(invocation.agent, presentation);
549
+ return { kind, text: serializeIntakePresentation(presentation, 4096) };
550
+ };
551
+ const diagnostic = promptDiagnostic(operation, invocation.attachments);
552
+ if (diagnostic !== undefined) {
553
+ const presentation = createIntakePresentation(`presentation-${randomUUID()}`, "error", diagnostic);
554
+ return complete(presentation, "error");
462
555
  }
463
556
  if (invocation.attachments.length > 0 && !["create", "action-finish"].includes(operation.operation)) {
464
557
  const presentation = createIntakePresentation(
465
558
  `presentation-${randomUUID()}`, "error", { code: "WSR_COMMAND_INVALID", message: "WSR_COMMAND_INVALID" },
466
559
  );
467
- if (!query) presentToDshSession(invocation.agent, presentation);
468
- return { kind: "error", text: serializeIntakePresentation(presentation, 4096) };
560
+ return complete(presentation, "error");
561
+ }
562
+ if (["create", "recover"].includes(operation.operation)) {
563
+ presentationRouter.retain(String(invocation.agent.id), invocation.agent);
469
564
  }
470
565
  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
566
  if (["create", "recover"].includes(operation.operation) && !["START_UNCERTAIN", "RECOVERY"].includes(result.kind)) {
472
567
  presentationRouter.release(String(invocation.agent.id));
473
568
  }
474
569
  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) };
570
+ return complete(presentation, result.kind === "ERROR" ? "error" : "success");
477
571
  } catch (cause) {
478
572
  const { createIntakePresentation, serializeIntakePresentation } = await import("wsr-execution");
479
573
  const code = typeof cause?.code === "string" ? cause.code : "DSH_INTAKE_FAILED";
480
574
  const presentation = createIntakePresentation(`presentation-${randomUUID()}`, "error", { code, message: code });
481
- if (!query) presentToDshSession(invocation.agent, presentation);
575
+ presentToDshSession(invocation.agent, presentation);
482
576
  return { kind: "error", text: serializeIntakePresentation(presentation, 4096) };
483
577
  }
484
578
  })());