create-windy 0.6.14 → 0.6.15

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.
Files changed (160) hide show
  1. package/dist/cli.js +215 -24
  2. package/package.json +1 -1
  3. package/template/.agents/skills/windy-business-module/SKILL.md +58 -8
  4. package/template/.windy-template.json +2 -2
  5. package/template/apps/server/src/agent/client-session/attempt-authority.test.ts +162 -0
  6. package/template/apps/server/src/agent/client-session/attempt-authority.ts +191 -0
  7. package/template/apps/server/src/agent/client-session/bootstrap.ts +27 -11
  8. package/template/apps/server/src/agent/client-session/completion.ts +75 -24
  9. package/template/apps/server/src/agent/client-session/contracts.ts +23 -0
  10. package/template/apps/server/src/agent/client-session/creation.ts +103 -0
  11. package/template/apps/server/src/agent/client-session/digest.ts +11 -1
  12. package/template/apps/server/src/agent/client-session/drizzle-records.ts +150 -0
  13. package/template/apps/server/src/agent/client-session/drizzle-repository.integration.test.ts +30 -3
  14. package/template/apps/server/src/agent/client-session/drizzle-repository.ts +23 -132
  15. package/template/apps/server/src/agent/client-session/finalization.ts +99 -0
  16. package/template/apps/server/src/agent/client-session/host-auth.ts +22 -10
  17. package/template/apps/server/src/agent/client-session/host-revocation-composition.test.ts +77 -0
  18. package/template/apps/server/src/agent/client-session/host-revocation-composition.ts +28 -0
  19. package/template/apps/server/src/agent/client-session/launch-authority.contract.test.ts +21 -0
  20. package/template/apps/server/src/agent/client-session/manifest-admission.ts +11 -0
  21. package/template/apps/server/src/agent/client-session/service.test-support.ts +22 -0
  22. package/template/apps/server/src/agent/client-session/service.ts +104 -134
  23. package/template/apps/server/src/agent/client-session/terminal-digest.test.ts +23 -0
  24. package/template/apps/server/src/agent/client-workflow-artifact/active-blob-references.ts +8 -0
  25. package/template/apps/server/src/agent/client-workflow-artifact/artifact-builder.ts +60 -0
  26. package/template/apps/server/src/agent/client-workflow-artifact/authority-current.ts +59 -0
  27. package/template/apps/server/src/agent/client-workflow-artifact/binding-registry.test.ts +189 -0
  28. package/template/apps/server/src/agent/client-workflow-artifact/binding-registry.ts +153 -0
  29. package/template/apps/server/src/agent/client-workflow-artifact/bootstrap.ts +77 -0
  30. package/template/apps/server/src/agent/client-workflow-artifact/cancellation.ts +47 -0
  31. package/template/apps/server/src/agent/client-workflow-artifact/completed-recovery.ts +31 -0
  32. package/template/apps/server/src/agent/client-workflow-artifact/contracts.ts +104 -0
  33. package/template/apps/server/src/agent/client-workflow-artifact/drizzle-locks.ts +30 -0
  34. package/template/apps/server/src/agent/client-workflow-artifact/drizzle-mapper.ts +138 -0
  35. package/template/apps/server/src/agent/client-workflow-artifact/drizzle-repository.ts +346 -0
  36. package/template/apps/server/src/agent/client-workflow-artifact/error.ts +21 -0
  37. package/template/apps/server/src/agent/client-workflow-artifact/handler.ts +167 -0
  38. package/template/apps/server/src/agent/client-workflow-artifact/host-revocation.ts +29 -0
  39. package/template/apps/server/src/agent/client-workflow-artifact/in-memory-repository.ts +232 -0
  40. package/template/apps/server/src/agent/client-workflow-artifact/job-port.ts +101 -0
  41. package/template/apps/server/src/agent/client-workflow-artifact/presentation.ts +41 -0
  42. package/template/apps/server/src/agent/client-workflow-artifact/recovery.contract.test.ts +253 -0
  43. package/template/apps/server/src/agent/client-workflow-artifact/release-binding.ts +85 -0
  44. package/template/apps/server/src/agent/client-workflow-artifact/repository.test.ts +204 -0
  45. package/template/apps/server/src/agent/client-workflow-artifact/repository.ts +97 -0
  46. package/template/apps/server/src/agent/client-workflow-artifact/routes-handler.contract.test.ts +201 -0
  47. package/template/apps/server/src/agent/client-workflow-artifact/routes.ts +161 -0
  48. package/template/apps/server/src/agent/client-workflow-artifact/runtime-composition.ts +101 -0
  49. package/template/apps/server/src/agent/client-workflow-artifact/scope-authority.ts +39 -0
  50. package/template/apps/server/src/agent/client-workflow-artifact/service-authorization.ts +117 -0
  51. package/template/apps/server/src/agent/client-workflow-artifact/service-support.ts +28 -0
  52. package/template/apps/server/src/agent/client-workflow-artifact/service.contract.test.ts +345 -0
  53. package/template/apps/server/src/agent/client-workflow-artifact/service.ts +350 -0
  54. package/template/apps/server/src/agent/client-workflow-artifact/session-boundary.ts +39 -0
  55. package/template/apps/server/src/agent/client-workflow-artifact/source-access.contract.test.ts +98 -0
  56. package/template/apps/server/src/agent/client-workflow-artifact/source-access.ts +80 -0
  57. package/template/apps/server/src/agent/client-workflow-artifact/takeover.ts +122 -0
  58. package/template/apps/server/src/agent/client-workflow-artifact/task-authority.contract.test.ts +97 -0
  59. package/template/apps/server/src/agent/client-workflow-artifact/task-authority.ts +147 -0
  60. package/template/apps/server/src/agent/client-workflow-artifact/terminal-authority.contract.test.ts +100 -0
  61. package/template/apps/server/src/agent/client-workflow-artifact/terminal-replay.ts +31 -0
  62. package/template/apps/server/src/agent/client-workflow-artifact/terminal-seal.ts +211 -0
  63. package/template/apps/server/src/agent/client-workflow-artifact/workflow-artifact.test-support.ts +173 -0
  64. package/template/apps/server/src/agent/client-workflow-artifact/workflow-loader.contract.test.ts +254 -0
  65. package/template/apps/server/src/agent/client-workflow-artifact/workflow-loader.ts +285 -0
  66. package/template/apps/server/src/agent/run-facade.test-support.ts +17 -4
  67. package/template/apps/server/src/agent/run-facade.test.ts +16 -0
  68. package/template/apps/server/src/agent/run-session-authorization.ts +12 -10
  69. package/template/apps/server/src/agent/runtime-bootstrap-input.ts +5 -1
  70. package/template/apps/server/src/agent/runtime-bootstrap.ts +33 -33
  71. package/template/apps/server/src/agent/runtime-public-ports.ts +40 -0
  72. package/template/apps/server/src/agent/session-invalidation/contracts.ts +25 -26
  73. package/template/apps/server/src/agent/session-projection-access.ts +31 -19
  74. package/template/apps/server/src/agent/unavailable-routes.ts +21 -0
  75. package/template/apps/server/src/agent/workflow-runtime-bootstrap.ts +2 -0
  76. package/template/apps/server/src/agent-workflow/artifact-source-authorization.ts +12 -6
  77. package/template/apps/server/src/agent-workflow/command-facade.ts +70 -11
  78. package/template/apps/server/src/agent-workflow/drizzle-artifact-port.ts +9 -9
  79. package/template/apps/server/src/agent-workflow/drizzle-delegation-completion.ts +1 -1
  80. package/template/apps/server/src/agent-workflow/platform-node-adapters.ts +14 -6
  81. package/template/apps/server/src/agent-workflow/project-launch-authority.contract.test.ts +53 -0
  82. package/template/apps/server/src/agent-workflow/project-launch-authority.ts +92 -0
  83. package/template/apps/server/src/agent-workflow/runtime-composition.ts +30 -14
  84. package/template/apps/server/src/app/agent-runtime-composition.ts +6 -1
  85. package/template/apps/server/src/app/client-agent-workflow-artifact-binding.ts +14 -0
  86. package/template/apps/server/src/application-services.ts +6 -0
  87. package/template/apps/server/src/data-access/scope-containment.test.ts +31 -0
  88. package/template/apps/server/src/data-access/scope-containment.ts +48 -0
  89. package/template/apps/server/src/jobs/artifact-publication.test.ts +192 -0
  90. package/template/apps/server/src/jobs/artifact-publication.ts +56 -0
  91. package/template/apps/server/src/jobs/artifact-publisher.ts +101 -0
  92. package/template/apps/server/src/jobs/drizzle-artifact.ts +73 -1
  93. package/template/apps/server/src/jobs/drizzle-execution-repository.ts +14 -1
  94. package/template/apps/server/src/jobs/drizzle-execution.integration.test.ts +65 -0
  95. package/template/apps/server/src/jobs/drizzle-repository.integration.test.ts +1 -0
  96. package/template/apps/server/src/jobs/execution-repository.ts +28 -0
  97. package/template/apps/server/src/jobs/execution.test.ts +3 -1
  98. package/template/apps/server/src/jobs/in-memory-execution.ts +16 -0
  99. package/template/apps/server/src/jobs/in-memory-lease.ts +45 -1
  100. package/template/apps/server/src/jobs/legacy-adapter.integration.test.ts +2 -0
  101. package/template/apps/server/src/jobs/postgres-cleanup-contract.test.ts +58 -0
  102. package/template/apps/server/src/jobs/worker.ts +8 -14
  103. package/template/apps/server/src/module-host/background-ai-unavailable.ts +18 -0
  104. package/template/apps/server/src/module-host/durable-context.ts +19 -22
  105. package/template/apps/server/src/module-host/implementation-completeness.test.ts +80 -1
  106. package/template/apps/server/src/module-host/implementation-completeness.ts +28 -1
  107. package/template/apps/server/src/module-host/request-context.ts +32 -8
  108. package/template/apps/server/src/module-host/storage-port.ts +3 -0
  109. package/template/apps/server/src/module-host/workflow-input-artifact-port.test.ts +104 -0
  110. package/template/apps/server/src/module-host/workflow-input-artifact-port.ts +229 -0
  111. package/template/apps/server/src/module-host/workflow-input-materialization.test.ts +163 -0
  112. package/template/apps/server/src/module-host/workflow-input-materialization.ts +161 -0
  113. package/template/apps/server/src/workflow/drizzle-repository.integration.test.ts +1 -0
  114. package/template/docs/architecture/agent-workflow.md +13 -2
  115. package/template/docs/architecture/durable-jobs.md +7 -0
  116. package/template/docs/operations/agent-session-memory.md +2 -1
  117. package/template/docs/operations/agent-session-troubleshooting.md +87 -2
  118. package/template/docs/operations/agent-workflow-recovery.md +25 -14
  119. package/template/docs/platform/client-agent-session.md +148 -2
  120. package/template/packages/ai-client/package.json +2 -1
  121. package/template/packages/ai-client/src/client-agent/index.ts +1 -0
  122. package/template/packages/ai-client/src/client-agent/workflow-artifact/contracts.ts +85 -0
  123. package/template/packages/ai-client/src/client-agent/workflow-artifact/http.test.ts +49 -0
  124. package/template/packages/ai-client/src/client-agent/workflow-artifact/http.ts +82 -0
  125. package/template/packages/ai-client/src/client-agent/workflow-artifact/index.ts +3 -0
  126. package/template/packages/ai-client/src/client-agent/workflow-artifact/validation.test.ts +154 -0
  127. package/template/packages/ai-client/src/client-agent/workflow-artifact/validation.ts +332 -0
  128. package/template/packages/database/drizzle/0062_client_agent_workflow_artifact.sql +63 -0
  129. package/template/packages/database/drizzle/meta/0062_snapshot.json +14839 -0
  130. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  131. package/template/packages/database/package.json +1 -1
  132. package/template/packages/database/src/agent-runtime-schema.test.ts +177 -0
  133. package/template/packages/database/src/schema/client-agent-workflow-artifact.ts +158 -0
  134. package/template/packages/database/src/schema/index.ts +1 -0
  135. package/template/packages/jobs/package.json +1 -1
  136. package/template/packages/jobs/src/handlers.test.ts +10 -0
  137. package/template/packages/jobs/src/handlers.ts +28 -11
  138. package/template/packages/modules/package.json +1 -1
  139. package/template/packages/modules/src/ai-client-execution-policy.ts +6 -0
  140. package/template/packages/modules/src/manifest.ts +2 -0
  141. package/template/packages/modules/src/system-agent-audit-actions.ts +3 -0
  142. package/template/packages/modules/src/system-ai-api-bindings.ts +15 -0
  143. package/template/packages/modules/src/system-audit-actions.ts +15 -0
  144. package/template/packages/server-sdk/index.ts +52 -0
  145. package/template/packages/server-sdk/package.json +5 -1
  146. package/template/packages/server-sdk/src/client-agent-session.ts +13 -1
  147. package/template/packages/server-sdk/src/client-agent-workflow-artifact.contract.test.ts +113 -0
  148. package/template/packages/server-sdk/src/client-agent-workflow-artifact.ts +183 -0
  149. package/template/packages/server-sdk/src/durable-handler.ts +7 -0
  150. package/template/packages/server-sdk/src/request-context.ts +7 -0
  151. package/template/packages/server-sdk/src/workflow-input-artifact-port.ts +104 -0
  152. package/template/packages/server-sdk/src/workflow-input-artifact-validation.contract.test.ts +124 -0
  153. package/template/packages/server-sdk/src/workflow-input-artifact-validation.ts +347 -0
  154. package/template/packages/server-sdk/src/workflow-input-materialization-handler.ts +43 -0
  155. package/template/packages/storage/package.json +1 -1
  156. package/template/packages/storage/src/blob-store-put-once.test.ts +70 -0
  157. package/template/packages/storage/src/blob-store.ts +9 -0
  158. package/template/packages/storage/src/local-file-blob-put-once.ts +120 -0
  159. package/template/packages/storage/src/local-file-blob-store.ts +53 -1
  160. package/template/packages/storage/src/memory-blob-store.ts +73 -33
@@ -0,0 +1,345 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { MemoryBlobStore } from "@southwind-ai/storage";
3
+ import {
4
+ createFixture,
5
+ validCompletion,
6
+ } from "../client-session/service.test-support.js";
7
+ import { hostSessionDigest } from "../client-session/host-auth.js";
8
+ import { buildScopeContext } from "../../data-access/context.js";
9
+ import { InMemoryClientAgentWorkflowArtifactRepository } from "./in-memory-repository.js";
10
+ import { GovernedClientAgentWorkflowArtifactService } from "./service.js";
11
+ import { ClientAgentWorkflowTaskAuthority } from "./task-authority.js";
12
+ import {
13
+ clientBindingDigest,
14
+ guardContext,
15
+ principal,
16
+ workflowBinding,
17
+ workflowLoader,
18
+ workflowRecord,
19
+ workflowSignal,
20
+ } from "./workflow-artifact.test-support.js";
21
+
22
+ describe("Governed Client Agent Workflow Artifact service contract", () => {
23
+ test("authority 与 effect fence 复验后才持久化 pending,再签发短期 claim", async () => {
24
+ const fixture = await createWorkflowFixture();
25
+
26
+ const begun = await fixture.service.begin({
27
+ jobId: "job-1",
28
+ payload: {
29
+ commandId: "command-1",
30
+ runId: "run-1",
31
+ nodeRunId: "node-run-1",
32
+ effectGeneration: 1,
33
+ requestDigest: workflowRecord().requestDigest,
34
+ },
35
+ signal: workflowSignal,
36
+ });
37
+
38
+ expect(fixture.trace).toEqual([
39
+ "authority-fence-current",
40
+ "persist-pending",
41
+ ]);
42
+ expect(begun.record).toMatchObject({
43
+ status: "pending",
44
+ effectGeneration: 1,
45
+ effectFenceTokenDigest: workflowRecord().effectFenceTokenDigest,
46
+ });
47
+
48
+ const claimed = await fixture.service.claim(claimInput());
49
+ expect(claimed).toMatchObject({ status: "running", claimVersion: 1 });
50
+ if (!("sessionGrant" in claimed) || !("claimToken" in claimed)) {
51
+ throw new Error("claim grant missing");
52
+ }
53
+ expect(claimed.claimToken).toStartWith("cawc_");
54
+ expect(claimed.sessionGrant).toMatchObject({
55
+ modelAlias: "windy-default",
56
+ capabilities: ["pageAgentClientRun"],
57
+ });
58
+ expect(JSON.stringify(claimed)).not.toContain("providerUrl");
59
+ expect(JSON.stringify(claimed)).not.toContain("apiKey");
60
+ });
61
+
62
+ test("receipt chain 只写一次 stable Blob,completed retry 不重放页面副作用", async () => {
63
+ const fixture = await createWorkflowFixture();
64
+ await begin(fixture.service);
65
+ const claimed = await fixture.service.claim(claimInput());
66
+ if (!("sessionGrant" in claimed) || !("claimToken" in claimed)) {
67
+ throw new Error("claim grant missing");
68
+ }
69
+ const completion = await fixture.sessions.service.complete(
70
+ claimed.sessionGrant.sessionId,
71
+ validCompletion(),
72
+ {
73
+ token: claimed.sessionGrant.accessToken,
74
+ actorId: principal.actorId,
75
+ clientBindingDigest,
76
+ hostSessionDigest: hostSessionDigest(principal),
77
+ },
78
+ );
79
+ const input = {
80
+ workId: claimed.workId,
81
+ claimVersion: claimed.claimVersion,
82
+ claimToken: claimed.claimToken,
83
+ receiptIds: [completion.receipt.receiptId],
84
+ result: { answer: "客户端投影" },
85
+ principal,
86
+ guardContext,
87
+ signal: workflowSignal,
88
+ } as const;
89
+
90
+ const completed = await fixture.service.advance(input);
91
+ expect(completed).toMatchObject({ status: "completed" });
92
+ expect(fixture.blobs.size).toBe(1);
93
+ const persisted = await fixture.repository.find(claimed.workId);
94
+ expect(persisted).toMatchObject({
95
+ status: "completed",
96
+ historySequence: 1,
97
+ terminalReceiptId: expect.stringMatching(/^cawr_/),
98
+ terminalReceiptDigest: expect.stringMatching(/^sha256:/),
99
+ artifact: { sha256: expect.stringMatching(/^[a-f0-9]{64}$/) },
100
+ });
101
+
102
+ fixture.loaderState.active = false;
103
+ await expect(fixture.service.advance(input)).resolves.toEqual(completed);
104
+ expect(fixture.trace.at(-1)).toBe("terminal-authority-current");
105
+ await expect(
106
+ fixture.service.advance({ ...input, result: { answer: "漂移" } }),
107
+ ).rejects.toMatchObject({ code: "CONFLICT" });
108
+ await expect(
109
+ fixture.service.advance({ ...input, receiptIds: ["receipt_drift"] }),
110
+ ).rejects.toMatchObject({ code: "CONFLICT" });
111
+ await expect(
112
+ fixture.service.advance({
113
+ ...input,
114
+ guardContext: {
115
+ ...guardContext,
116
+ actor: { ...guardContext.actor, permissionKeys: [] },
117
+ },
118
+ }),
119
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
120
+ const driftedScope = buildScopeContext(principal.actorId, undefined, [
121
+ {
122
+ id: "role-drifted",
123
+ code: "drifted",
124
+ dataScope: "tenant",
125
+ departmentIds: [],
126
+ },
127
+ ]);
128
+ await expect(
129
+ fixture.service.advance({
130
+ ...input,
131
+ guardContext: {
132
+ ...guardContext,
133
+ actor: {
134
+ ...guardContext.actor,
135
+ dataAccess: driftedScope,
136
+ scopeContext: driftedScope,
137
+ },
138
+ },
139
+ }),
140
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
141
+ expect(fixture.blobs.size).toBe(1);
142
+ expect(fixture.sessions.provider.requests).toHaveLength(1);
143
+ });
144
+
145
+ test("Session finalize 后桥接前崩溃,重启只读终态恢复 Blob 与桥接", async () => {
146
+ const fixture = await createWorkflowFixture();
147
+ await begin(fixture.service);
148
+ const claimed = await fixture.service.claim(claimInput());
149
+ if (!("sessionGrant" in claimed) || !("claimToken" in claimed)) {
150
+ throw new Error("claim grant missing");
151
+ }
152
+ const completion = await fixture.sessions.service.complete(
153
+ claimed.sessionGrant.sessionId,
154
+ validCompletion(),
155
+ {
156
+ token: claimed.sessionGrant.accessToken,
157
+ actorId: principal.actorId,
158
+ clientBindingDigest,
159
+ hostSessionDigest: hostSessionDigest(principal),
160
+ },
161
+ );
162
+ const receiptIds = [completion.receipt.receiptId];
163
+ await fixture.sessions.service.finalizeWorkflow(
164
+ claimed.sessionGrant.sessionId,
165
+ {
166
+ actorId: principal.actorId,
167
+ clientBindingDigest,
168
+ hostSessionDigest: hostSessionDigest(principal),
169
+ },
170
+ receiptIds,
171
+ { answer: "客户端投影" },
172
+ async (proposedResult) =>
173
+ workflowBinding.terminalResultAuthority.canonicalize({
174
+ workflowInput: { question: "研判当前页面线索" },
175
+ inputArtifacts: [],
176
+ proposedResult,
177
+ actorId: principal.actorId,
178
+ scopeDigest: workflowRecord().scopeDigest,
179
+ signal: workflowSignal,
180
+ }),
181
+ );
182
+ const interrupted = await fixture.repository.find(claimed.workId);
183
+ expect(interrupted?.status).toBe("claimed");
184
+ expect(interrupted?.artifact).toBeUndefined();
185
+ expect(fixture.blobs.size).toBe(0);
186
+
187
+ const restarted = new GovernedClientAgentWorkflowArtifactService(
188
+ fixture.repository,
189
+ fixture.loader,
190
+ fixture.sessions.service,
191
+ fixture.sessions.repository,
192
+ fixture.blobs,
193
+ new ClientAgentWorkflowTaskAuthority(),
194
+ () => fixture.sessions.clock.value,
195
+ );
196
+ const recovered = await restarted.reconcileOrCancel({
197
+ jobId: "job-1",
198
+ cancel: false,
199
+ signal: workflowSignal,
200
+ });
201
+ expect(recovered).toMatchObject({
202
+ status: "completed",
203
+ terminalRequestDigest: expect.stringMatching(/^sha256:/),
204
+ artifact: { artifactRef: expect.stringMatching(/^blob_/) },
205
+ });
206
+ await expect(
207
+ restarted.reconcileOrCancel({
208
+ jobId: "job-1",
209
+ cancel: false,
210
+ signal: workflowSignal,
211
+ }),
212
+ ).resolves.toEqual(recovered);
213
+ expect(fixture.blobs.size).toBe(1);
214
+ expect(fixture.sessions.provider.requests).toHaveLength(1);
215
+ });
216
+
217
+ test("旧 claim generation 与 token 在 takeover 后失去 attempt authority", async () => {
218
+ const fixture = await createWorkflowFixture();
219
+ await begin(fixture.service);
220
+ const first = await fixture.service.claim(claimInput());
221
+ const second = await fixture.service.claim(claimInput());
222
+ if (
223
+ !("claimToken" in first) ||
224
+ !("sessionGrant" in first) ||
225
+ !("claimToken" in second)
226
+ ) {
227
+ throw new Error("claim grant missing");
228
+ }
229
+ expect(second.claimVersion).toBe(2);
230
+ await expect(
231
+ fixture.service.advance({
232
+ workId: first.workId,
233
+ claimVersion: first.claimVersion,
234
+ claimToken: first.claimToken,
235
+ receiptIds: ["receipt-from-stale-attempt"],
236
+ principal,
237
+ guardContext,
238
+ signal: workflowSignal,
239
+ }),
240
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
241
+ expect(
242
+ (await fixture.sessions.repository.find(first.sessionGrant.sessionId))
243
+ ?.status,
244
+ ).toBe("revoked");
245
+ expect(fixture.sessions.provider.requests).toHaveLength(0);
246
+ });
247
+
248
+ test("canonical Workflow authority 漂移时,Provider attempt 出网前 fail closed", async () => {
249
+ const fixture = await createWorkflowFixture();
250
+ await begin(fixture.service);
251
+ const claimed = await fixture.service.claim(claimInput());
252
+ if (!("sessionGrant" in claimed)) throw new Error("claim grant missing");
253
+ await fixture.repository.requestCancel({
254
+ workId: claimed.workId,
255
+ now: fixture.sessions.clock.value,
256
+ });
257
+
258
+ await expect(
259
+ fixture.sessions.service.complete(
260
+ claimed.sessionGrant.sessionId,
261
+ validCompletion(),
262
+ {
263
+ token: claimed.sessionGrant.accessToken,
264
+ actorId: principal.actorId,
265
+ clientBindingDigest,
266
+ hostSessionDigest: hostSessionDigest(principal),
267
+ },
268
+ ),
269
+ ).rejects.toMatchObject({ code: "CONFLICT" });
270
+ expect(fixture.sessions.provider.requests).toHaveLength(0);
271
+ expect(
272
+ (await fixture.sessions.repository.find(claimed.sessionGrant.sessionId))
273
+ ?.status,
274
+ ).toBe("revoked");
275
+ });
276
+ });
277
+
278
+ async function createWorkflowFixture() {
279
+ const trace: string[] = [];
280
+ const loaderState = { active: true, terminal: true };
281
+ class TraceRepository extends InMemoryClientAgentWorkflowArtifactRepository {
282
+ override async begin(record: ReturnType<typeof workflowRecord>) {
283
+ trace.push("persist-pending");
284
+ return super.begin(record);
285
+ }
286
+ }
287
+ const repository = new TraceRepository();
288
+ const sessions = await createFixture({
289
+ attemptAuthority: {
290
+ async check(facts) {
291
+ const current = await repository.findForActorRun({
292
+ actorId: facts.actorId,
293
+ runId: "run-1",
294
+ });
295
+ if (!current) return "ordinary";
296
+ return current.status === "claimed" &&
297
+ current.clientSessionId === facts.sessionId &&
298
+ current.clientBindingDigest === facts.clientBindingDigest &&
299
+ current.hostSessionDigest === facts.hostSessionDigest
300
+ ? "current"
301
+ : "revoked";
302
+ },
303
+ },
304
+ });
305
+ const blobs = new MemoryBlobStore();
306
+ const loader = workflowLoader({
307
+ trace,
308
+ active: () => loaderState.active,
309
+ terminal: () => loaderState.terminal,
310
+ });
311
+ const service = new GovernedClientAgentWorkflowArtifactService(
312
+ repository,
313
+ loader,
314
+ sessions.service,
315
+ sessions.repository,
316
+ blobs,
317
+ new ClientAgentWorkflowTaskAuthority(),
318
+ () => sessions.clock.value,
319
+ );
320
+ return { service, repository, sessions, blobs, trace, loader, loaderState };
321
+ }
322
+
323
+ function claimInput() {
324
+ return {
325
+ workflowRunId: "run-1",
326
+ clientBindingDigest,
327
+ principal,
328
+ guardContext,
329
+ signal: workflowSignal,
330
+ };
331
+ }
332
+
333
+ function begin(service: GovernedClientAgentWorkflowArtifactService) {
334
+ return service.begin({
335
+ jobId: "job-1",
336
+ payload: {
337
+ commandId: "command-1",
338
+ runId: "run-1",
339
+ nodeRunId: "node-run-1",
340
+ effectGeneration: 1,
341
+ requestDigest: workflowRecord().requestDigest,
342
+ },
343
+ signal: workflowSignal,
344
+ });
345
+ }
@@ -0,0 +1,350 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import type {
3
+ ModuleAiJsonValue,
4
+ ModuleClientAgentHostPrincipal,
5
+ } from "@southwind-ai/server-sdk";
6
+ import type { BlobStore } from "@southwind-ai/storage";
7
+ import type { RequestGuardContext } from "../../guards.js";
8
+ import type { GovernedClientAgentSessionService } from "../client-session/service.js";
9
+ import type { ClientSessionRepository } from "../client-session/repository.js";
10
+ import { digestText, validDigest } from "../client-session/digest.js";
11
+ import { hostSessionDigest } from "../client-session/host-auth.js";
12
+ import type { ClientAgentWorkflowArtifactRecord } from "./contracts.js";
13
+ import type { ClientAgentWorkflowArtifactRepository } from "./repository.js";
14
+ import type * as WorkflowLoader from "./workflow-loader.js";
15
+ import {
16
+ clientWorkflowPayloadOf,
17
+ clientWorkflowPublicView,
18
+ } from "./presentation.js";
19
+ import {
20
+ requiredWorkflowArtifactText as required,
21
+ workflowArtifactActor as actor,
22
+ workflowArtifactConflict as conflict,
23
+ workflowArtifactHidden as hidden,
24
+ workflowArtifactInvalid as invalid,
25
+ } from "./service-support.js";
26
+ import {
27
+ fenceResumableClientWorkflowClaim,
28
+ reconcileClientWorkflowTakeover,
29
+ } from "./takeover.js";
30
+ import { clientWorkflowSessionCall } from "./session-boundary.js";
31
+ import {
32
+ cancelClientWorkflowArtifact,
33
+ recoverClientWorkflowCancellations,
34
+ } from "./cancellation.js";
35
+ import { sealClientWorkflowArtifact } from "./terminal-seal.js";
36
+ import { recoverCompletedClientWorkflowArtifact } from "./completed-recovery.js";
37
+ import { revokeClientWorkflowArtifactHost } from "./host-revocation.js";
38
+ import { replayCompletedClientWorkflowArtifact } from "./terminal-replay.js";
39
+ import {
40
+ requireClientWorkflowClaim,
41
+ requireCurrentClientWorkflowArtifact,
42
+ } from "./service-authorization.js";
43
+ import { ClientAgentWorkflowTaskAuthority } from "./task-authority.js";
44
+
45
+ export class GovernedClientAgentWorkflowArtifactService {
46
+ readonly #recovery: Promise<number>;
47
+
48
+ constructor(
49
+ private readonly repository: ClientAgentWorkflowArtifactRepository,
50
+ private readonly loader: WorkflowLoader.ClientAgentWorkflowFactsLoader,
51
+ private readonly sessions: GovernedClientAgentSessionService,
52
+ private readonly sessionRepository: ClientSessionRepository,
53
+ private readonly blobs: BlobStore,
54
+ private readonly tasks: ClientAgentWorkflowTaskAuthority = new ClientAgentWorkflowTaskAuthority(),
55
+ private readonly now: () => number = Date.now,
56
+ ) {
57
+ this.#recovery = recoverClientWorkflowCancellations({
58
+ repository: this.repository,
59
+ sessions: this.sessions,
60
+ now: this.now,
61
+ });
62
+ }
63
+
64
+ async begin(input: {
65
+ jobId: string;
66
+ payload: WorkflowLoader.ClientAgentWorkflowJobPayload;
67
+ signal: AbortSignal;
68
+ }) {
69
+ await this.#recovery;
70
+ const facts = await this.loader.load(input);
71
+ const begun = await this.repository.begin(facts.record);
72
+ return {
73
+ record: await this.recoverCompleted(begun, input.signal),
74
+ input: facts.input,
75
+ binding: facts.binding,
76
+ };
77
+ }
78
+
79
+ async claim(input: {
80
+ workflowRunId: string;
81
+ clientBindingDigest: string;
82
+ principal: ModuleClientAgentHostPrincipal;
83
+ guardContext: RequestGuardContext;
84
+ signal: AbortSignal;
85
+ }) {
86
+ await this.#recovery;
87
+ if (!validDigest(input.clientBindingDigest)) throw invalid("绑定摘要无效");
88
+ const actorId = actor(input.guardContext);
89
+ if (input.principal.actorId !== actorId) throw hidden();
90
+ const current = await this.repository.findForActorRun({
91
+ actorId,
92
+ runId: required(input.workflowRunId),
93
+ });
94
+ if (!current) throw hidden();
95
+ const initialFacts = await this.requireCurrent(
96
+ current,
97
+ input.guardContext,
98
+ input.signal,
99
+ );
100
+ if (current.status === "completed" || current.status === "cancelled") {
101
+ return clientWorkflowPublicView(
102
+ current,
103
+ initialFacts.binding.artifact.kind,
104
+ );
105
+ }
106
+ const claimToken = `cawc_${randomBytes(32).toString("base64url")}`;
107
+ const hostDigest = hostSessionDigest(input.principal);
108
+ const allowActiveTakeover = await fenceResumableClientWorkflowClaim({
109
+ record: current,
110
+ binding: {
111
+ clientBindingDigest: input.clientBindingDigest,
112
+ installationId: input.principal.installationId,
113
+ hostSessionDigest: hostDigest,
114
+ },
115
+ sessionRepository: this.sessionRepository,
116
+ sessions: this.sessions,
117
+ repository: this.repository,
118
+ now: this.now(),
119
+ });
120
+ const claimed = await this.repository.claim({
121
+ workId: current.workId,
122
+ expectedVersion: current.version,
123
+ allowActiveTakeover,
124
+ binding: {
125
+ clientBindingDigest: input.clientBindingDigest as `sha256:${string}`,
126
+ installationId: required(input.principal.installationId),
127
+ hostSessionDigest: hostDigest,
128
+ claimTokenHash: digestText(claimToken),
129
+ claimExpiresAt: this.now() + 5 * 60_000,
130
+ now: this.now(),
131
+ },
132
+ });
133
+ if (claimed.outcome !== "claimed") {
134
+ if (
135
+ claimed.record &&
136
+ ["completed", "cancelled"].includes(claimed.record.status)
137
+ ) {
138
+ return clientWorkflowPublicView(
139
+ claimed.record,
140
+ initialFacts.binding.artifact.kind,
141
+ );
142
+ }
143
+ throw conflict("领取状态已变化,请重试");
144
+ }
145
+ await reconcileClientWorkflowTakeover({
146
+ previousClientSessionId: claimed.previousClientSessionId,
147
+ workId: current.workId,
148
+ sessions: this.sessions,
149
+ sessionRepository: this.sessionRepository,
150
+ repository: this.repository,
151
+ now: this.now(),
152
+ });
153
+ const facts = await this.loader.load({
154
+ jobId: current.durableJobId,
155
+ payload: clientWorkflowPayloadOf(current),
156
+ signal: input.signal,
157
+ });
158
+ const task = await this.tasks.build({
159
+ facts,
160
+ actorId,
161
+ scopeDigest: claimed.record.scopeDigest,
162
+ signal: input.signal,
163
+ });
164
+ const grant = await clientWorkflowSessionCall(
165
+ this.sessions.createWorkflow({
166
+ operationKey: claimed.record.operationKey,
167
+ input: facts.input,
168
+ clientBindingDigest: input.clientBindingDigest,
169
+ hostSessionDigest: claimed.record.hostSessionDigest!,
170
+ actorId,
171
+ }),
172
+ "create",
173
+ );
174
+ const bound = await this.repository.bindClientSession({
175
+ workId: current.workId,
176
+ claimGeneration: claimed.record.claimGeneration,
177
+ clientSessionId: grant.sessionId,
178
+ now: this.now(),
179
+ });
180
+ if (!bound) {
181
+ await this.sessions.revokeSession(grant.sessionId, "workflow-claim-race");
182
+ throw conflict("领取状态已失效");
183
+ }
184
+ return {
185
+ ...clientWorkflowPublicView(bound, facts.binding.artifact.kind),
186
+ claimToken,
187
+ sessionGrant: grant,
188
+ task,
189
+ };
190
+ }
191
+
192
+ async advance(input: {
193
+ workId: string;
194
+ claimVersion: number;
195
+ claimToken: string;
196
+ receiptIds: readonly string[];
197
+ result?: ModuleAiJsonValue;
198
+ principal: ModuleClientAgentHostPrincipal;
199
+ guardContext: RequestGuardContext;
200
+ signal: AbortSignal;
201
+ }) {
202
+ await this.#recovery;
203
+ const current = await this.requireClaim(input);
204
+ if (current.status === "completed") {
205
+ return replayCompletedClientWorkflowArtifact({
206
+ record: current,
207
+ receiptIds: input.receiptIds,
208
+ ...(input.result === undefined ? {} : { result: input.result }),
209
+ context: input.guardContext,
210
+ signal: input.signal,
211
+ loader: this.loader,
212
+ });
213
+ }
214
+ if (current.status === "cancelled") throw hidden();
215
+ const facts = await this.requireCurrent(
216
+ current,
217
+ input.guardContext,
218
+ input.signal,
219
+ );
220
+ return clientWorkflowPublicView(
221
+ await sealClientWorkflowArtifact({
222
+ current,
223
+ claimToken: input.claimToken,
224
+ receiptIds: input.receiptIds,
225
+ ...(input.result === undefined ? {} : { result: input.result }),
226
+ signal: input.signal,
227
+ sessions: this.sessions,
228
+ sessionRepository: this.sessionRepository,
229
+ repository: this.repository,
230
+ loader: this.loader,
231
+ blobs: this.blobs,
232
+ tasks: this.tasks,
233
+ now: this.now,
234
+ }),
235
+ facts.binding.artifact.kind,
236
+ );
237
+ }
238
+
239
+ async cancelClaim(input: {
240
+ workId: string;
241
+ claimVersion: number;
242
+ claimToken: string;
243
+ principal: ModuleClientAgentHostPrincipal;
244
+ guardContext: RequestGuardContext;
245
+ signal: AbortSignal;
246
+ }) {
247
+ await this.#recovery;
248
+ const current = await this.requireClaim(input);
249
+ const facts = await this.requireCurrent(
250
+ current,
251
+ input.guardContext,
252
+ input.signal,
253
+ );
254
+ if (current.status === "completed" || current.status === "cancelled") {
255
+ return clientWorkflowPublicView(current, facts.binding.artifact.kind);
256
+ }
257
+ const cancelled = await cancelClientWorkflowArtifact({
258
+ record: current,
259
+ repository: this.repository,
260
+ sessions: this.sessions,
261
+ cause: "workflow-client-cancel",
262
+ now: this.now(),
263
+ });
264
+ if (!cancelled) throw conflict("已完成产物不可取消");
265
+ return clientWorkflowPublicView(cancelled, facts.binding.artifact.kind);
266
+ }
267
+
268
+ async reconcileOrCancel(input: {
269
+ jobId: string;
270
+ cancel: boolean;
271
+ signal: AbortSignal;
272
+ }) {
273
+ await this.#recovery;
274
+ const current = await this.repository.findByJob(input.jobId);
275
+ if (!current) return undefined;
276
+ if (
277
+ (!input.cancel && current.status !== "cancellation-requested") ||
278
+ current.status === "completed"
279
+ ) {
280
+ return input.cancel
281
+ ? current
282
+ : this.recoverCompleted(current, input.signal);
283
+ }
284
+ return cancelClientWorkflowArtifact({
285
+ record: current,
286
+ repository: this.repository,
287
+ sessions: this.sessions,
288
+ cause: "workflow-effect-cancelled",
289
+ now: this.now(),
290
+ });
291
+ }
292
+
293
+ async revokeHost(principal: ModuleClientAgentHostPrincipal) {
294
+ await this.#recovery;
295
+ const digest = hostSessionDigest(principal);
296
+ const records = await this.repository.listClaimedByHost({
297
+ actorId: principal.actorId,
298
+ hostSessionDigest: digest,
299
+ });
300
+ return revokeClientWorkflowArtifactHost({
301
+ records,
302
+ repository: this.repository,
303
+ sessions: this.sessions,
304
+ now: this.now,
305
+ });
306
+ }
307
+
308
+ private async requireClaim(input: {
309
+ workId: string;
310
+ claimVersion: number;
311
+ claimToken: string;
312
+ principal: ModuleClientAgentHostPrincipal;
313
+ guardContext: RequestGuardContext;
314
+ }) {
315
+ return requireClientWorkflowClaim({
316
+ ...input,
317
+ repository: this.repository,
318
+ sessionRepository: this.sessionRepository,
319
+ now: this.now(),
320
+ });
321
+ }
322
+
323
+ private async requireCurrent(
324
+ record: ClientAgentWorkflowArtifactRecord,
325
+ context: RequestGuardContext,
326
+ signal: AbortSignal,
327
+ ) {
328
+ return requireCurrentClientWorkflowArtifact({
329
+ record,
330
+ context,
331
+ signal,
332
+ loader: this.loader,
333
+ });
334
+ }
335
+
336
+ private recoverCompleted(
337
+ current: ClientAgentWorkflowArtifactRecord,
338
+ signal: AbortSignal,
339
+ ) {
340
+ return recoverCompletedClientWorkflowArtifact({
341
+ current,
342
+ signal,
343
+ sessionRepository: this.sessionRepository,
344
+ repository: this.repository,
345
+ loader: this.loader,
346
+ blobs: this.blobs,
347
+ now: this.now,
348
+ });
349
+ }
350
+ }