create-windy 0.6.14 → 0.6.16

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 (162) hide show
  1. package/dist/cli.js +1008 -61
  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/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.vue +2 -1
  115. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.webtest.ts +16 -0
  116. package/template/docs/architecture/agent-workflow.md +13 -2
  117. package/template/docs/architecture/durable-jobs.md +7 -0
  118. package/template/docs/operations/agent-session-memory.md +2 -1
  119. package/template/docs/operations/agent-session-troubleshooting.md +87 -2
  120. package/template/docs/operations/agent-workflow-recovery.md +25 -14
  121. package/template/docs/platform/client-agent-session.md +148 -2
  122. package/template/packages/ai-client/package.json +2 -1
  123. package/template/packages/ai-client/src/client-agent/index.ts +1 -0
  124. package/template/packages/ai-client/src/client-agent/workflow-artifact/contracts.ts +85 -0
  125. package/template/packages/ai-client/src/client-agent/workflow-artifact/http.test.ts +49 -0
  126. package/template/packages/ai-client/src/client-agent/workflow-artifact/http.ts +82 -0
  127. package/template/packages/ai-client/src/client-agent/workflow-artifact/index.ts +3 -0
  128. package/template/packages/ai-client/src/client-agent/workflow-artifact/validation.test.ts +154 -0
  129. package/template/packages/ai-client/src/client-agent/workflow-artifact/validation.ts +332 -0
  130. package/template/packages/database/drizzle/0062_client_agent_workflow_artifact.sql +63 -0
  131. package/template/packages/database/drizzle/meta/0062_snapshot.json +14839 -0
  132. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  133. package/template/packages/database/package.json +1 -1
  134. package/template/packages/database/src/agent-runtime-schema.test.ts +177 -0
  135. package/template/packages/database/src/schema/client-agent-workflow-artifact.ts +158 -0
  136. package/template/packages/database/src/schema/index.ts +1 -0
  137. package/template/packages/jobs/package.json +1 -1
  138. package/template/packages/jobs/src/handlers.test.ts +10 -0
  139. package/template/packages/jobs/src/handlers.ts +28 -11
  140. package/template/packages/modules/package.json +1 -1
  141. package/template/packages/modules/src/ai-client-execution-policy.ts +6 -0
  142. package/template/packages/modules/src/manifest.ts +2 -0
  143. package/template/packages/modules/src/system-agent-audit-actions.ts +3 -0
  144. package/template/packages/modules/src/system-ai-api-bindings.ts +15 -0
  145. package/template/packages/modules/src/system-audit-actions.ts +15 -0
  146. package/template/packages/server-sdk/index.ts +52 -0
  147. package/template/packages/server-sdk/package.json +5 -1
  148. package/template/packages/server-sdk/src/client-agent-session.ts +13 -1
  149. package/template/packages/server-sdk/src/client-agent-workflow-artifact.contract.test.ts +113 -0
  150. package/template/packages/server-sdk/src/client-agent-workflow-artifact.ts +183 -0
  151. package/template/packages/server-sdk/src/durable-handler.ts +7 -0
  152. package/template/packages/server-sdk/src/request-context.ts +7 -0
  153. package/template/packages/server-sdk/src/workflow-input-artifact-port.ts +104 -0
  154. package/template/packages/server-sdk/src/workflow-input-artifact-validation.contract.test.ts +124 -0
  155. package/template/packages/server-sdk/src/workflow-input-artifact-validation.ts +347 -0
  156. package/template/packages/server-sdk/src/workflow-input-materialization-handler.ts +43 -0
  157. package/template/packages/storage/package.json +1 -1
  158. package/template/packages/storage/src/blob-store-put-once.test.ts +70 -0
  159. package/template/packages/storage/src/blob-store.ts +9 -0
  160. package/template/packages/storage/src/local-file-blob-put-once.ts +120 -0
  161. package/template/packages/storage/src/local-file-blob-store.ts +53 -1
  162. package/template/packages/storage/src/memory-blob-store.ts +73 -33
@@ -6,11 +6,14 @@ import {
6
6
  projectAiScreenshotPageRegistryV2,
7
7
  } from "./ai-screenshot-intake-binding.js";
8
8
  import { projectClientAgentHostAuthAdapter } from "./client-agent-host-auth-binding.js";
9
+ import { projectClientAgentWorkflowArtifactBindings } from "./client-agent-workflow-artifact-binding.js";
9
10
 
10
11
  export function composeApplicationAgentRuntime(
11
12
  input: Omit<
12
13
  AgentPlatformRuntimeInput,
13
- "clientAgentHostAuth" | "screenshotPageRegistry"
14
+ | "clientAgentHostAuth"
15
+ | "clientAgentWorkflowArtifactBindings"
16
+ | "screenshotPageRegistry"
14
17
  > & {
15
18
  readonly bindAuthorizationInvalidation: (
16
19
  target: AgentAuthorizationInvalidationTarget | undefined,
@@ -23,6 +26,8 @@ export function composeApplicationAgentRuntime(
23
26
  const runtime = registerAgentPlatformRuntime({
24
27
  ...input,
25
28
  clientAgentHostAuth: projectClientAgentHostAuthAdapter,
29
+ clientAgentWorkflowArtifactBindings:
30
+ projectClientAgentWorkflowArtifactBindings,
26
31
  screenshotPageRegistry: input.platformConfig.enableAI
27
32
  ? projectAiScreenshotPageRegistryV2
28
33
  : undefined,
@@ -0,0 +1,14 @@
1
+ import type { ModuleClientAgentWorkflowArtifactProjectBinding } from "@southwind-ai/server-sdk";
2
+
3
+ /**
4
+ * Project-owned Workflow -> PageAgent 绑定。Starter 默认空数组并 fail closed;
5
+ * 产品只为已审核、已发布的固定 Workflow Job node 增加静态绑定。Operation 必须声明
6
+ * `clientExecution.launchAuthority="workflow-artifact"`;binding 必须登记 Immutable
7
+ * canonical `releaseDigest`、scope containment contract v1,以及 project launch、client task、terminal
8
+ * authority version/digest。launch authority 从 Host 当前 scope 显式收窄;terminal authority
9
+ * 同时复验 Host 重授权并打开的 canonical input artifacts。project preset 使用
10
+ * runtime.agentWorkflowProjects;普通 routes 永远不能发布/启动受保护 Workflow。
11
+ * 发布 Release 或 authority contract 后必须重新审核并显式更新。
12
+ */
13
+ export const projectClientAgentWorkflowArtifactBindings: readonly ModuleClientAgentWorkflowArtifactProjectBinding[] =
14
+ [];
@@ -43,6 +43,9 @@ import type { DurableHandlerRegistry } from "@southwind-ai/jobs";
43
43
  // @windy-module system.storage begin
44
44
  import { collectActiveBlobRefs } from "./storage/blob-reference-registry.js";
45
45
  // @windy-module system.storage end
46
+ // @windy-module system.agent begin
47
+ import { clientWorkflowArtifactActiveBlobReferences } from "./agent/client-workflow-artifact/active-blob-references.js";
48
+ // @windy-module system.agent end
46
49
  import type { createSystemRepositories } from "./system/seed.js";
47
50
  // @windy-module system.data-governance begin
48
51
  import { identityRouteConfigs } from "./system/identity-route-config.js";
@@ -175,6 +178,9 @@ export async function createApplicationServices(
175
178
  // @windy-module system.bulk-data begin
176
179
  bulkRepository,
177
180
  // @windy-module system.bulk-data end
181
+ // @windy-module system.agent begin
182
+ ...clientWorkflowArtifactActiveBlobReferences(persistence?.db),
183
+ // @windy-module system.agent end
178
184
  ],
179
185
  now,
180
186
  );
@@ -0,0 +1,31 @@
1
+ import { expect, test } from "bun:test";
2
+ import type { AuthorizationScopeSnapshot } from "@southwind-ai/shared";
3
+ import { authorizationScopeContains } from "./scope-containment.js";
4
+
5
+ const base = {
6
+ tenantId: "single-tenant",
7
+ actorId: "actor-1",
8
+ roleIds: ["analyst"],
9
+ } satisfies Omit<AuthorizationScopeSnapshot, "grants">;
10
+
11
+ test("当前多案 custom scope 可包含冻结单案 scope", () => {
12
+ const current: AuthorizationScopeSnapshot = {
13
+ ...base,
14
+ grants: [{ scope: "custom", dimension: "caseId", values: ["a", "b"] }],
15
+ };
16
+ const frozen: AuthorizationScopeSnapshot = {
17
+ ...base,
18
+ grants: [{ scope: "custom", dimension: "caseId", values: ["a"] }],
19
+ };
20
+ expect(authorizationScopeContains(current, frozen)).toBe(true);
21
+ expect(
22
+ authorizationScopeContains(current, {
23
+ ...frozen,
24
+ roleIds: ["role-no-longer-held"],
25
+ }),
26
+ ).toBe(false);
27
+ expect(authorizationScopeContains(frozen, current)).toBe(false);
28
+ expect(
29
+ authorizationScopeContains({ ...current, actorId: "other-actor" }, frozen),
30
+ ).toBe(false);
31
+ });
@@ -0,0 +1,48 @@
1
+ import type {
2
+ AuthorizationScopeSnapshot,
3
+ PersistedScopeGrant,
4
+ } from "@southwind-ai/shared";
5
+
6
+ /** 当前 Actor scope 必须覆盖冻结 Run scope;不允许反向恢复更宽权限。 */
7
+ export function authorizationScopeContains(
8
+ current: AuthorizationScopeSnapshot,
9
+ frozen: AuthorizationScopeSnapshot,
10
+ ): boolean {
11
+ return (
12
+ current.actorId === frozen.actorId &&
13
+ current.tenantId === frozen.tenantId &&
14
+ frozen.roleIds.every((roleId) => current.roleIds.includes(roleId)) &&
15
+ frozen.grants.every((required) =>
16
+ current.grants.some((grant) => grantContains(grant, required)),
17
+ )
18
+ );
19
+ }
20
+
21
+ function grantContains(
22
+ current: PersistedScopeGrant,
23
+ frozen: PersistedScopeGrant,
24
+ ): boolean {
25
+ if (current.scope === "all" || current.scope === "tenant") return true;
26
+ if (current.scope === "self") return frozen.scope === "self";
27
+ if (current.scope === "custom") {
28
+ return (
29
+ frozen.scope === "custom" &&
30
+ current.dimension === frozen.dimension &&
31
+ frozen.values.every((value) => current.values.includes(value))
32
+ );
33
+ }
34
+ if (current.scope === "department-and-children") {
35
+ return (
36
+ (frozen.scope === "department" ||
37
+ frozen.scope === "department-and-children") &&
38
+ Boolean(current.departmentId) &&
39
+ current.departmentId === frozen.departmentId
40
+ );
41
+ }
42
+ return (
43
+ current.scope === "department" &&
44
+ frozen.scope === "department" &&
45
+ Boolean(current.departmentId) &&
46
+ current.departmentId === frozen.departmentId
47
+ );
48
+ }
@@ -0,0 +1,192 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ DurableHandlerRegistry,
4
+ type DurableArtifactPublicationReceipt,
5
+ } from "@southwind-ai/jobs";
6
+ import { artifactRef } from "@southwind-ai/storage";
7
+ import { DurableArtifactPublicationConflictError } from "./execution-repository.js";
8
+ import { InMemoryDurableExecutionRepository } from "./in-memory-execution.js";
9
+ import { DurableJobService } from "./service.js";
10
+ import { DurableJobWorker } from "./worker.js";
11
+
12
+ const base = new Date("2026-08-28T09:00:00.000Z");
13
+ const artifact = {
14
+ kind: "workflow-result",
15
+ artifactRef: artifactRef("blob_0123456789abcdef0123456789abcdef"),
16
+ filename: "result.json",
17
+ contentType: "application/json",
18
+ byteSize: 12,
19
+ sha256: "a".repeat(64),
20
+ ownerId: "user_1",
21
+ metadata: { nested: { second: 2, first: 1 } },
22
+ };
23
+
24
+ describe("durable artifact stable publication", () => {
25
+ test("repository 对相同发布返回首发事实并拒绝内容漂移", async () => {
26
+ const repository = new InMemoryDurableExecutionRepository();
27
+ const service = new DurableJobService(repository, () => base);
28
+ const submitted = await service.submit(jobInput("repository-once"));
29
+ const claim = await repository.claim(claimInput("worker-repository"));
30
+ if (!claim) throw new Error("任务未领取");
31
+ const publication = {
32
+ id: "dap_stable",
33
+ jobId: claim.jobId,
34
+ stageId: claim.stageId,
35
+ ...artifact,
36
+ };
37
+
38
+ await expect(
39
+ repository.publishArtifactOnce(
40
+ claim,
41
+ { ...publication, jobId: "job_forged" },
42
+ base,
43
+ ),
44
+ ).rejects.toBeInstanceOf(DurableArtifactPublicationConflictError);
45
+ await expect(
46
+ repository.publishArtifactOnce(
47
+ claim,
48
+ { ...publication, stageId: "stage_forged" },
49
+ base,
50
+ ),
51
+ ).rejects.toBeInstanceOf(DurableArtifactPublicationConflictError);
52
+ expect((await service.get(submitted.job.id))?.artifacts).toEqual([]);
53
+
54
+ const first = await repository.publishArtifactOnce(
55
+ claim,
56
+ publication,
57
+ base,
58
+ );
59
+ const replay = await repository.publishArtifactOnce(
60
+ claim,
61
+ {
62
+ ...publication,
63
+ metadata: { nested: { first: 1, second: 2 } },
64
+ },
65
+ new Date(base.getTime() + 1),
66
+ );
67
+
68
+ expect(replay).toEqual(first);
69
+ await expect(
70
+ repository.publishArtifactOnce(
71
+ claim,
72
+ { ...publication, filename: "drifted.json" },
73
+ new Date(base.getTime() + 2),
74
+ ),
75
+ ).rejects.toBeInstanceOf(DurableArtifactPublicationConflictError);
76
+ await expect(
77
+ repository.publishArtifactOnce(
78
+ claim,
79
+ { ...publication, kind: "other-result" },
80
+ new Date(base.getTime() + 3),
81
+ ),
82
+ ).rejects.toBeInstanceOf(DurableArtifactPublicationConflictError);
83
+ expect((await service.get(submitted.job.id))?.artifacts).toEqual([
84
+ expect.objectContaining({
85
+ id: "dap_stable",
86
+ filename: "result.json",
87
+ createdAt: base.toISOString(),
88
+ }),
89
+ ]);
90
+ });
91
+
92
+ test("Worker 从稳定坐标生成同一 receipt", async () => {
93
+ const repository = new InMemoryDurableExecutionRepository();
94
+ const service = new DurableJobService(repository, () => base);
95
+ const submitted = await service.submit(jobInput("worker-once"));
96
+ const receipts: DurableArtifactPublicationReceipt[] = [];
97
+ const handlers = new DurableHandlerRegistry().register({
98
+ key: "test.handler",
99
+ payload: { parse: () => ({}) },
100
+ execute: async (_payload, context) => {
101
+ receipts.push(
102
+ await context.publishArtifactOnce!("page-agent-final", artifact),
103
+ );
104
+ receipts.push(
105
+ await context.publishArtifactOnce!("page-agent-final", artifact),
106
+ );
107
+ },
108
+ });
109
+ const worker = new DurableJobWorker(repository, handlers, {
110
+ now: () => base,
111
+ });
112
+
113
+ expect(await worker.runNext("default", "worker-a")).toMatchObject({
114
+ status: "succeeded",
115
+ });
116
+ expect(receipts).toHaveLength(2);
117
+ expect(receipts[1]).toEqual(receipts[0]);
118
+ expect(receipts[0]?.artifactId).toMatch(/^dap_[a-f0-9]{60}$/);
119
+ expect((await service.get(submitted.job.id))?.artifacts).toEqual([
120
+ expect.objectContaining({ id: receipts[0]?.artifactId }),
121
+ ]);
122
+ });
123
+
124
+ test("取消请求阻止一次性产物写入", async () => {
125
+ const repository = new InMemoryDurableExecutionRepository();
126
+ const service = new DurableJobService(repository, () => base);
127
+ const submitted = await service.submit(jobInput("repository-cancelled"));
128
+ const claim = await repository.claim(claimInput("worker-cancelled"));
129
+ if (!claim) throw new Error("取消任务未领取");
130
+ await repository.requestCancel(claim.jobId, base);
131
+
132
+ expect(
133
+ await repository.publishArtifactOnce(
134
+ claim,
135
+ {
136
+ id: "dap_cancelled",
137
+ jobId: claim.jobId,
138
+ stageId: claim.stageId,
139
+ ...artifact,
140
+ },
141
+ base,
142
+ ),
143
+ ).toMatchObject({ ok: true, cancelRequested: true });
144
+ expect((await service.get(submitted.job.id))?.artifacts).toEqual([]);
145
+ });
146
+
147
+ test("Worker 将同 publicationKey 的漂移固化为不可重试失败", async () => {
148
+ const repository = new InMemoryDurableExecutionRepository();
149
+ const service = new DurableJobService(repository, () => base);
150
+ const submitted = await service.submit(jobInput("worker-conflict"));
151
+ const handlers = new DurableHandlerRegistry().register({
152
+ key: "test.handler",
153
+ payload: { parse: () => ({}) },
154
+ execute: async (_payload, context) => {
155
+ await context.publishArtifactOnce!("page-agent-final", artifact);
156
+ await context.publishArtifactOnce!("page-agent-final", {
157
+ ...artifact,
158
+ sha256: "b".repeat(64),
159
+ });
160
+ },
161
+ });
162
+ const worker = new DurableJobWorker(repository, handlers, {
163
+ now: () => base,
164
+ });
165
+
166
+ expect(await worker.runNext("default", "worker-a")).toMatchObject({
167
+ status: "failed",
168
+ });
169
+ expect(await service.get(submitted.job.id)).toMatchObject({
170
+ artifacts: [{ sha256: "a".repeat(64) }],
171
+ failures: [
172
+ {
173
+ code: "ARTIFACT_PUBLICATION_CONFLICT",
174
+ retryable: false,
175
+ },
176
+ ],
177
+ });
178
+ });
179
+ });
180
+
181
+ function jobInput(idempotencyKey: string) {
182
+ return {
183
+ handlerKey: "test.handler",
184
+ idempotencyKey,
185
+ payload: {},
186
+ requestedBy: "user_1",
187
+ };
188
+ }
189
+
190
+ function claimInput(workerId: string) {
191
+ return { queue: "default", workerId, now: base, leaseDurationMs: 30_000 };
192
+ }
@@ -0,0 +1,56 @@
1
+ import type {
2
+ DurableJobArtifact,
3
+ DurableLeaseIdentity,
4
+ } from "@southwind-ai/jobs";
5
+ import {
6
+ DurableArtifactPublicationConflictError,
7
+ type DurableArtifactPublication,
8
+ } from "./execution-repository.js";
9
+
10
+ export function assertArtifactPublicationIdentity(
11
+ identity: DurableLeaseIdentity,
12
+ candidate: DurableArtifactPublication,
13
+ ) {
14
+ if (
15
+ candidate.jobId !== identity.jobId ||
16
+ candidate.stageId !== identity.stageId
17
+ ) {
18
+ throw new DurableArtifactPublicationConflictError(
19
+ "产物发布身份与任务租约不匹配",
20
+ );
21
+ }
22
+ }
23
+
24
+ export function sameArtifactPublication(
25
+ persisted: DurableJobArtifact,
26
+ candidate: DurableArtifactPublication,
27
+ ): boolean {
28
+ return (
29
+ persisted.id === candidate.id &&
30
+ persisted.jobId === candidate.jobId &&
31
+ persisted.stageId === candidate.stageId &&
32
+ persisted.kind === candidate.kind &&
33
+ persisted.artifactRef === candidate.artifactRef &&
34
+ persisted.filename === candidate.filename &&
35
+ persisted.contentType === candidate.contentType &&
36
+ persisted.byteSize === candidate.byteSize &&
37
+ persisted.sha256 === candidate.sha256 &&
38
+ persisted.ownerId === candidate.ownerId &&
39
+ persisted.expiresAt === candidate.expiresAt &&
40
+ canonicalJson(persisted.metadata) === canonicalJson(candidate.metadata)
41
+ );
42
+ }
43
+
44
+ function canonicalJson(value: unknown): string | undefined {
45
+ return JSON.stringify(canonicalValue(value));
46
+ }
47
+
48
+ function canonicalValue(value: unknown): unknown {
49
+ if (Array.isArray(value)) return value.map(canonicalValue);
50
+ if (!value || typeof value !== "object") return value;
51
+ return Object.fromEntries(
52
+ Object.entries(value)
53
+ .sort(([left], [right]) => left.localeCompare(right))
54
+ .map(([key, entry]) => [key, canonicalValue(entry)]),
55
+ );
56
+ }
@@ -0,0 +1,101 @@
1
+ import {
2
+ DurableHandlerError,
3
+ type DurableHandlerContext,
4
+ type DurableLeaseIdentity,
5
+ } from "@southwind-ai/jobs";
6
+ import { createHash } from "node:crypto";
7
+ import {
8
+ DurableArtifactPublicationConflictError,
9
+ type DurableExecutionRepository,
10
+ type LeaseResult,
11
+ } from "./execution-repository.js";
12
+
13
+ export function createDurableArtifactPublisher(input: {
14
+ readonly repository: DurableExecutionRepository;
15
+ readonly identity: DurableLeaseIdentity;
16
+ readonly stageKey: string;
17
+ readonly now: () => Date;
18
+ readonly assertLease: (result: LeaseResult) => void;
19
+ }): Required<
20
+ Pick<DurableHandlerContext, "publishArtifact" | "publishArtifactOnce">
21
+ > {
22
+ return {
23
+ publishArtifact: async (artifact) => {
24
+ const result = await input.repository.publishArtifact(
25
+ input.identity,
26
+ {
27
+ id: Bun.randomUUIDv7(),
28
+ jobId: input.identity.jobId,
29
+ stageId: input.identity.stageId,
30
+ ...artifact,
31
+ createdAt: input.now().toISOString(),
32
+ },
33
+ input.now(),
34
+ );
35
+ input.assertLease(result);
36
+ },
37
+ publishArtifactOnce: async (publicationKey, artifact) => {
38
+ validatePublicationKey(publicationKey);
39
+ let result;
40
+ try {
41
+ result = await input.repository.publishArtifactOnce(
42
+ input.identity,
43
+ {
44
+ id: stableArtifactId(input.identity, publicationKey),
45
+ jobId: input.identity.jobId,
46
+ stageId: input.identity.stageId,
47
+ ...artifact,
48
+ },
49
+ input.now(),
50
+ );
51
+ } catch (error) {
52
+ if (error instanceof DurableArtifactPublicationConflictError) {
53
+ throw new DurableHandlerError(
54
+ "ARTIFACT_PUBLICATION_CONFLICT",
55
+ error.message,
56
+ false,
57
+ );
58
+ }
59
+ throw error;
60
+ }
61
+ if (!result.ok) {
62
+ input.assertLease(result);
63
+ throw new Error("产物发布租约校验未终结");
64
+ }
65
+ input.assertLease(result);
66
+ if (result.cancelRequested) {
67
+ throw new Error("已取消任务不应返回产物发布事实");
68
+ }
69
+ return {
70
+ schemaVersion: 1,
71
+ publicationKey,
72
+ artifactId: result.artifact.id,
73
+ jobId: input.identity.jobId,
74
+ stageKey: input.stageKey,
75
+ kind: result.artifact.kind,
76
+ artifactRef: result.artifact.artifactRef,
77
+ sha256: result.artifact.sha256,
78
+ };
79
+ },
80
+ };
81
+ }
82
+
83
+ function validatePublicationKey(value: string) {
84
+ if (!value || value !== value.trim() || value.length > 160) {
85
+ throw new DurableHandlerError(
86
+ "ARTIFACT_PUBLICATION_KEY_INVALID",
87
+ "产物 publicationKey 必须为 1 到 160 个非空白字符",
88
+ false,
89
+ );
90
+ }
91
+ }
92
+
93
+ function stableArtifactId(
94
+ identity: Pick<DurableLeaseIdentity, "jobId" | "stageId">,
95
+ publicationKey: string,
96
+ ) {
97
+ const digest = createHash("sha256")
98
+ .update(JSON.stringify([identity.jobId, identity.stageId, publicationKey]))
99
+ .digest("hex");
100
+ return `dap_${digest.slice(0, 60)}`;
101
+ }
@@ -4,13 +4,21 @@ import {
4
4
  durableJobs,
5
5
  } from "@southwind-ai/database";
6
6
  import type { DurableLeaseIdentity } from "@southwind-ai/jobs";
7
- import { eq } from "drizzle-orm";
7
+ import { and, eq, or } from "drizzle-orm";
8
8
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
9
9
  import type {
10
+ DurableArtifactPublication,
11
+ DurableArtifactPublicationResult,
10
12
  DurableExecutionRepository,
11
13
  LeaseResult,
12
14
  } from "./execution-repository.js";
15
+ import { DurableArtifactPublicationConflictError } from "./execution-repository.js";
13
16
  import { activeLeaseWhere } from "./drizzle-lease.js";
17
+ import {
18
+ assertArtifactPublicationIdentity,
19
+ sameArtifactPublication,
20
+ } from "./artifact-publication.js";
21
+ import { mapArtifact } from "./drizzle-mappers.js";
14
22
 
15
23
  type Artifact = Parameters<DurableExecutionRepository["publishArtifact"]>[1];
16
24
 
@@ -53,6 +61,70 @@ export function publishDrizzleArtifact(
53
61
  });
54
62
  }
55
63
 
64
+ export function publishDrizzleArtifactOnce(
65
+ db: NodePgDatabase,
66
+ identity: DurableLeaseIdentity,
67
+ artifact: DurableArtifactPublication,
68
+ now: Date,
69
+ leaseFailure: () => Promise<LeaseResult>,
70
+ ): Promise<DurableArtifactPublicationResult> {
71
+ return db.transaction(async (tx) => {
72
+ const [lease] = await tx
73
+ .select()
74
+ .from(durableJobLeases)
75
+ .where(activeLeaseWhere(identity, now))
76
+ .for("update")
77
+ .limit(1);
78
+ if (!lease) {
79
+ const failure = await leaseFailure();
80
+ if (failure.ok) {
81
+ throw new Error("DURABLE_ARTIFACT_LEASE_FAILURE_INVALID");
82
+ }
83
+ return failure;
84
+ }
85
+ const [job] = await tx
86
+ .select({ cancelRequestedAt: durableJobs.cancelRequestedAt })
87
+ .from(durableJobs)
88
+ .where(eq(durableJobs.id, identity.jobId))
89
+ .for("update")
90
+ .limit(1);
91
+ if (!job) return { ok: false, reason: "not-found" } as const;
92
+ assertArtifactPublicationIdentity(identity, artifact);
93
+ const expiresAt = lease.expiresAt.toISOString();
94
+ if (job.cancelRequestedAt) {
95
+ return { ok: true, cancelRequested: true, expiresAt } as const;
96
+ }
97
+ const leaseResult = {
98
+ ok: true,
99
+ cancelRequested: false,
100
+ expiresAt,
101
+ } as const;
102
+ const candidate = { ...artifact, createdAt: now.toISOString() };
103
+ const [row] = await tx
104
+ .select()
105
+ .from(durableJobArtifacts)
106
+ .where(
107
+ or(
108
+ eq(durableJobArtifacts.id, artifact.id),
109
+ and(
110
+ eq(durableJobArtifacts.jobId, artifact.jobId),
111
+ eq(durableJobArtifacts.kind, artifact.kind),
112
+ ),
113
+ ),
114
+ )
115
+ .limit(1);
116
+ if (row) {
117
+ const existing = mapArtifact(row);
118
+ if (!sameArtifactPublication(existing, artifact)) {
119
+ throw new DurableArtifactPublicationConflictError();
120
+ }
121
+ return { ...leaseResult, artifact: existing };
122
+ }
123
+ await tx.insert(durableJobArtifacts).values(toDatabaseArtifact(candidate));
124
+ return { ...leaseResult, artifact: candidate };
125
+ });
126
+ }
127
+
56
128
  function toDatabaseArtifact(artifact: Artifact) {
57
129
  return {
58
130
  ...artifact,
@@ -20,7 +20,10 @@ import type {
20
20
  } from "./execution-repository.js";
21
21
  import { DrizzleDurableRecoveryRepository } from "./drizzle-recovery-repository.js";
22
22
  import { claimDurableJob } from "./drizzle-claim.js";
23
- import { publishDrizzleArtifact } from "./drizzle-artifact.js";
23
+ import {
24
+ publishDrizzleArtifact,
25
+ publishDrizzleArtifactOnce,
26
+ } from "./drizzle-artifact.js";
24
27
  import { activeLeaseWhere } from "./drizzle-lease.js";
25
28
 
26
29
  export class DrizzleDurableExecutionRepository implements DurableExecutionRepository {
@@ -95,6 +98,16 @@ export class DrizzleDurableExecutionRepository implements DurableExecutionReposi
95
98
  );
96
99
  }
97
100
 
101
+ async publishArtifactOnce(
102
+ identity: DurableLeaseIdentity,
103
+ artifact: Parameters<DurableExecutionRepository["publishArtifactOnce"]>[1],
104
+ now: Date,
105
+ ) {
106
+ return publishDrizzleArtifactOnce(this.db, identity, artifact, now, () =>
107
+ this.leaseFailure(identity, now),
108
+ );
109
+ }
110
+
98
111
  async complete(identity: DurableLeaseIdentity, now: Date) {
99
112
  return this.db.transaction(async (tx) => {
100
113
  const [lease] = await tx