create-windy 0.3.4 → 0.3.6

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 (101) hide show
  1. package/README.md +20 -3
  2. package/dist/cli.js +585 -111
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +4 -0
  6. package/template/README.md +18 -11
  7. package/template/apps/server/src/application-services.ts +16 -0
  8. package/template/apps/server/src/data-governance/export/authorization-port.test.ts +5 -1
  9. package/template/apps/server/src/data-governance/export/drizzle-repository.integration.test.ts +2 -0
  10. package/template/apps/server/src/data-governance/export/drizzle-repository.ts +1 -0
  11. package/template/apps/server/src/data-governance/export/job-runtime.test.ts +3 -1
  12. package/template/apps/server/src/data-governance/export/job-runtime.ts +53 -14
  13. package/template/apps/server/src/data-governance/export/ndjson-stream.ts +8 -0
  14. package/template/apps/server/src/data-governance/export/postgres-recovery.integration.test.ts +2 -0
  15. package/template/apps/server/src/data-governance/export/renderer-job-runtime.test.ts +235 -0
  16. package/template/apps/server/src/data-governance/export/renderer-runtime.ts +293 -0
  17. package/template/apps/server/src/data-governance/export/renderer-selection.test.ts +67 -0
  18. package/template/apps/server/src/data-governance/export/repository.test.ts +2 -0
  19. package/template/apps/server/src/data-governance/export/request-fingerprint.test.ts +13 -0
  20. package/template/apps/server/src/data-governance/export/request-fingerprint.ts +2 -0
  21. package/template/apps/server/src/data-governance/export/routes.ts +11 -1
  22. package/template/apps/server/src/data-governance/export/service-support.ts +61 -4
  23. package/template/apps/server/src/data-governance/export/service.ts +8 -2
  24. package/template/apps/server/src/data-governance/export/types.ts +27 -0
  25. package/template/apps/server/src/index.ts +1 -0
  26. package/template/apps/server/src/jobs/artifact-service.test.ts +14 -2
  27. package/template/apps/server/src/jobs/artifact-service.ts +10 -0
  28. package/template/apps/server/src/jobs/drizzle-execution-repository.ts +17 -18
  29. package/template/apps/server/src/jobs/drizzle-execution.integration.test.ts +19 -1
  30. package/template/apps/server/src/jobs/drizzle-mappers.ts +3 -1
  31. package/template/apps/server/src/jobs/drizzle-recovery-repository.ts +49 -2
  32. package/template/apps/server/src/jobs/execution-repository.ts +17 -0
  33. package/template/apps/server/src/jobs/in-memory-control.ts +135 -0
  34. package/template/apps/server/src/jobs/in-memory-execution.ts +34 -65
  35. package/template/apps/server/src/jobs/worker.ts +27 -3
  36. package/template/apps/server/src/module-host/governed-export-route-support.ts +24 -5
  37. package/template/apps/server/src/module-host/governed-export-routes.test.ts +5 -1
  38. package/template/apps/server/src/module-host/governed-export-routes.ts +1 -2
  39. package/template/apps/server/src/module-host/governed-renderer-registration.test.ts +247 -0
  40. package/template/apps/server/src/module-host/governed-resources.test.ts +1 -0
  41. package/template/apps/server/src/module-host/governed-resources.ts +56 -0
  42. package/template/apps/server/src/module-host/http-runtime.ts +3 -0
  43. package/template/apps/server/src/module-host/job-port.test.ts +262 -0
  44. package/template/apps/server/src/module-host/job-port.ts +334 -0
  45. package/template/apps/server/src/module-host/request-context.ts +14 -26
  46. package/template/apps/server/src/module-host/route-installer.ts +11 -0
  47. package/template/apps/web/src/app/business-routes.ts +22 -0
  48. package/template/apps/web/src/components/common/AccessSnapshotBoundary.vue +112 -0
  49. package/template/apps/web/src/components/common/AccessSnapshotBoundary.webtest.ts +130 -0
  50. package/template/apps/web/src/components/common/AccessSnapshotNotice.vue +117 -0
  51. package/template/apps/web/src/composables/access-snapshot-change.ts +131 -0
  52. package/template/apps/web/src/composables/access-snapshot-change.webtest.ts +103 -0
  53. package/template/apps/web/src/composables/useAccessSnapshot.ts +48 -2
  54. package/template/apps/web/src/composables/useAccessSnapshot.webtest.ts +54 -0
  55. package/template/apps/web/src/composables/useAccessSnapshotMonitor.ts +42 -0
  56. package/template/apps/web/src/layout/AppShell.vue +28 -25
  57. package/template/apps/web/src/layout/AppShell.webtest.ts +1 -0
  58. package/template/apps/web/src/router/business-routes.ts +68 -0
  59. package/template/apps/web/src/router/business-routes.webtest.ts +85 -0
  60. package/template/apps/web/src/router/index.ts +7 -0
  61. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +12 -0
  62. package/template/apps/web/src/services/access-api.ts +5 -1
  63. package/template/apps/web/src/services/access-api.webtest.ts +35 -0
  64. package/template/docs/architecture/ai-egress.md +12 -9
  65. package/template/docs/architecture/durable-jobs.md +7 -1
  66. package/template/docs/architecture/foundation-infrastructure.md +163 -0
  67. package/template/docs/architecture/module-host.md +115 -0
  68. package/template/docs/architecture/server-module-interface.md +39 -0
  69. package/template/docs/architecture/web-access-snapshot-resilience.md +60 -0
  70. package/template/docs/development/business-web-routes.md +66 -0
  71. package/template/docs/development/custom-login-page.md +3 -3
  72. package/template/docs/development/module-lifecycle.md +71 -0
  73. package/template/docs/operations/observability.md +5 -1
  74. package/template/docs/platform/agent-runtime.md +6 -6
  75. package/template/docs/platform/agent-tool-sandbox.md +58 -0
  76. package/template/docs/platform/document-ai-runtime.md +204 -0
  77. package/template/docs/platform/governed-export-renderers.md +132 -0
  78. package/template/docs/platform/navigation-registry.md +5 -0
  79. package/template/docs/platform/organization-membership.md +2 -1
  80. package/template/docs/platform/speech-ai-runtime.md +156 -0
  81. package/template/docs/platform/system-crud-api.md +7 -1
  82. package/template/docs/platform/vision-ai-runtime.md +97 -0
  83. package/template/docs/platform/web-system-crud.md +6 -0
  84. package/template/packages/config/package.json +1 -1
  85. package/template/packages/database/drizzle/0042_puzzling_rumiko_fujikawa.sql +27 -0
  86. package/template/packages/database/drizzle/meta/0042_snapshot.json +8541 -0
  87. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  88. package/template/packages/database/package.json +1 -1
  89. package/template/packages/database/src/schema/durable-jobs.ts +1 -0
  90. package/template/packages/database/src/schema/governed-exports.ts +15 -0
  91. package/template/packages/database/src/schema-governed-exports.test.ts +21 -1
  92. package/template/packages/jobs/package.json +1 -1
  93. package/template/packages/jobs/src/handlers.ts +1 -0
  94. package/template/packages/jobs/src/types.ts +1 -0
  95. package/template/packages/modules/package.json +1 -1
  96. package/template/packages/server-sdk/index.ts +11 -0
  97. package/template/packages/server-sdk/package.json +1 -1
  98. package/template/packages/server-sdk/src/data-governance-registration.ts +46 -0
  99. package/template/packages/server-sdk/src/jobs-port.ts +69 -0
  100. package/template/packages/server-sdk/src/request-context.ts +2 -2
  101. package/template/packages/shared/package.json +1 -1
@@ -1,13 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { MemoryBlobStore } from "@southwind-ai/storage";
3
3
  import { DurableArtifactService } from "./artifact-service.js";
4
- import { InMemoryDurableJobRepository } from "./repository.js";
4
+ import { InMemoryDurableExecutionRepository } from "./in-memory-execution.js";
5
5
  import { DurableJobService } from "./service.js";
6
6
 
7
7
  describe("DurableArtifactService", () => {
8
8
  test("只有 Owner 或 manage 权限可读取且过期后统一拒绝", async () => {
9
9
  let now = new Date("2026-07-16T00:00:00.000Z");
10
- const repository = new InMemoryDurableJobRepository();
10
+ const repository = new InMemoryDurableExecutionRepository();
11
11
  const jobs = new DurableJobService(repository, () => now);
12
12
  const submitted = await jobs.submit({
13
13
  handlerKey: "test.artifact",
@@ -70,6 +70,18 @@ describe("DurableArtifactService", () => {
70
70
  ).rejects.toMatchObject({
71
71
  code: "ARTIFACT_EXPIRED",
72
72
  });
73
+
74
+ now = new Date("2026-07-16T12:00:00.000Z");
75
+ await repository.requestCancel({
76
+ jobId: submitted.job.id,
77
+ expectedStatus: "queued",
78
+ now,
79
+ });
80
+ await expect(
81
+ service.open(submitted.job.id, "result", "owner-1"),
82
+ ).rejects.toMatchObject({
83
+ code: "ARTIFACT_JOB_CANCELLED",
84
+ });
73
85
  });
74
86
  });
75
87
 
@@ -5,6 +5,7 @@ import type { DurableJobRepository } from "./repository.js";
5
5
  export type DurableArtifactErrorCode =
6
6
  | "ARTIFACT_NOT_FOUND"
7
7
  | "ARTIFACT_FORBIDDEN"
8
+ | "ARTIFACT_JOB_CANCELLED"
8
9
  | "ARTIFACT_EXPIRED"
9
10
  | "ARTIFACT_CONTENT_MISSING";
10
11
 
@@ -54,6 +55,15 @@ export class DurableArtifactService {
54
55
  if (artifact.ownerId !== actorId && !allowManage) {
55
56
  throw new DurableArtifactError("ARTIFACT_FORBIDDEN", "无权访问任务产物");
56
57
  }
58
+ if (
59
+ snapshot?.job.status === "cancelled" ||
60
+ snapshot?.job.cancelRequestedAt
61
+ ) {
62
+ throw new DurableArtifactError(
63
+ "ARTIFACT_JOB_CANCELLED",
64
+ "已取消任务的产物不可访问",
65
+ );
66
+ }
57
67
  if (artifact.expiresAt && artifact.expiresAt <= this.now().toISOString()) {
58
68
  throw new DurableArtifactError("ARTIFACT_EXPIRED", "任务产物已过期");
59
69
  }
@@ -11,6 +11,8 @@ import { and, eq, max, notInArray } from "drizzle-orm";
11
11
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
12
12
  import type {
13
13
  ClaimDurableJob,
14
+ CancelDurableJob,
15
+ DurableJobControlResult,
14
16
  DurableExecutionRepository,
15
17
  FailDurableJob,
16
18
  LeaseResult,
@@ -220,24 +222,21 @@ export class DrizzleDurableExecutionRepository implements DurableExecutionReposi
220
222
  });
221
223
  }
222
224
 
223
- async requestCancel(jobId: string, now: Date) {
224
- return this.db.transaction(async (tx) => {
225
- const [job] = await tx
226
- .select()
227
- .from(durableJobs)
228
- .where(eq(durableJobs.id, jobId))
229
- .for("update")
230
- .limit(1);
231
- if (!job || !["queued", "running"].includes(job.status)) return false;
232
- if (job.status === "queued") await cancelRemaining(tx, job.id, now);
233
- else {
234
- await tx
235
- .update(durableJobs)
236
- .set({ cancelRequestedAt: now, updatedAt: now })
237
- .where(eq(durableJobs.id, job.id));
238
- }
239
- return true;
240
- });
225
+ requestCancel(
226
+ input: CancelDurableJob,
227
+ ): Promise<DurableJobControlResult>;
228
+ requestCancel(jobId: string, now: Date): Promise<boolean>;
229
+ async requestCancel(
230
+ input: CancelDurableJob | string,
231
+ now?: Date,
232
+ ) {
233
+ const legacy = typeof input === "string";
234
+ const result = await this.recovery.requestCancel(
235
+ legacy ? { jobId: input, now: now! } : input,
236
+ );
237
+ return legacy
238
+ ? ["changed", "unchanged"].includes(result.outcome)
239
+ : result;
241
240
  }
242
241
 
243
242
  sweepExpired(now: Date, retryDelayMs: number) {
@@ -180,7 +180,20 @@ describePostgres("PostgreSQL durable execution", () => {
180
180
  const execution = new DrizzleDurableExecutionRepository(drizzle(pool));
181
181
  const claim = await execution.claim(claimInput("worker-a", base, 10_000));
182
182
  if (!claim) throw new Error("任务未领取");
183
- expect(await execution.requestCancel(submitted.job.id, base)).toBe(true);
183
+ const running = await service.get(submitted.job.id);
184
+ if (!running) throw new Error("运行任务不存在");
185
+ const cancelInput = {
186
+ jobId: submitted.job.id,
187
+ expectedStatus: running.job.status,
188
+ expectedVersion: running.job.updatedAt,
189
+ now: base,
190
+ } as const;
191
+ expect(await execution.requestCancel(cancelInput)).toEqual({
192
+ outcome: "changed",
193
+ });
194
+ expect(await execution.requestCancel(cancelInput)).toEqual({
195
+ outcome: "unchanged",
196
+ });
184
197
  expect(await execution.heartbeat(claim, base, 10_000)).toMatchObject({
185
198
  ok: true,
186
199
  cancelRequested: true,
@@ -190,11 +203,16 @@ describePostgres("PostgreSQL durable execution", () => {
190
203
  ).toBe(1);
191
204
  expect((await service.get(submitted.job.id))?.job.status).toBe("cancelled");
192
205
 
206
+ const cancelled = await service.get(submitted.job.id);
207
+ if (!cancelled) throw new Error("取消任务不存在");
193
208
  const recovery = {
194
209
  jobId: submitted.job.id,
195
210
  requestedBy: "ops_user",
196
211
  reason: "确认下游依赖已经恢复",
197
212
  idempotencyKey: "pg-recovery-1",
213
+ expectedAttempt: 1,
214
+ expectedStatus: "cancelled" as const,
215
+ expectedVersion: cancelled.job.updatedAt,
198
216
  now: new Date(base.getTime() + 2_000),
199
217
  };
200
218
  const results = await Promise.all([
@@ -90,10 +90,12 @@ export function mapProgress(
90
90
  export function mapArtifact(
91
91
  row: typeof durableJobArtifacts.$inferSelect,
92
92
  ): DurableJobArtifact {
93
+ const { metadata, ...artifact } = row;
93
94
  return {
94
- ...row,
95
+ ...artifact,
95
96
  stageId: row.stageId ?? undefined,
96
97
  artifactRef: artifactRef(row.artifactRef),
98
+ ...(metadata ? { metadata } : {}),
97
99
  expiresAt: iso(row.expiresAt),
98
100
  createdAt: row.createdAt.toISOString(),
99
101
  };
@@ -6,14 +6,48 @@ import {
6
6
  durableJobStages,
7
7
  durableJobs,
8
8
  } from "@southwind-ai/database";
9
- import { and, eq, lte, notInArray, or } from "drizzle-orm";
9
+ import { and, eq, lte, max, notInArray, or } from "drizzle-orm";
10
10
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
11
- import type { ManualJobRecovery } from "./execution-repository.js";
11
+ import type {
12
+ CancelDurableJob,
13
+ ManualJobRecovery,
14
+ } from "./execution-repository.js";
12
15
  import { mapRecovery } from "./drizzle-mappers.js";
13
16
 
14
17
  export class DrizzleDurableRecoveryRepository {
15
18
  constructor(private readonly db: NodePgDatabase) {}
16
19
 
20
+ async requestCancel(input: CancelDurableJob) {
21
+ return this.db.transaction(async (tx) => {
22
+ const [job] = await tx
23
+ .select()
24
+ .from(durableJobs)
25
+ .where(eq(durableJobs.id, input.jobId))
26
+ .for("update")
27
+ .limit(1);
28
+ if (!job) return { outcome: "not-found" } as const;
29
+ if (job.status === "cancelled" || job.cancelRequestedAt) {
30
+ return { outcome: "unchanged" } as const;
31
+ }
32
+ if (
33
+ !["queued", "running"].includes(job.status) ||
34
+ (input.expectedStatus && job.status !== input.expectedStatus) ||
35
+ (input.expectedVersion &&
36
+ job.updatedAt.toISOString() !== input.expectedVersion)
37
+ ) {
38
+ return { outcome: "conflict" } as const;
39
+ }
40
+ if (job.status === "queued") await cancelRemaining(tx, job.id, input.now);
41
+ else {
42
+ await tx
43
+ .update(durableJobs)
44
+ .set({ cancelRequestedAt: input.now, updatedAt: input.now })
45
+ .where(eq(durableJobs.id, job.id));
46
+ }
47
+ return { outcome: "changed" } as const;
48
+ });
49
+ }
50
+
17
51
  async sweepExpired(now: Date, retryDelayMs: number) {
18
52
  return this.db.transaction(async (tx) => {
19
53
  const rows = await tx
@@ -139,6 +173,19 @@ export class DrizzleDurableRecoveryRepository {
139
173
  if (!["failed", "cancelled"].includes(job.status)) {
140
174
  throw new Error("只有失败或已取消任务可以人工恢复");
141
175
  }
176
+ const [{ attempt = 0 } = { attempt: 0 }] = await tx
177
+ .select({ attempt: max(durableJobStages.currentAttempt) })
178
+ .from(durableJobStages)
179
+ .where(eq(durableJobStages.jobId, job.id));
180
+ if (
181
+ (input.expectedStatus && job.status !== input.expectedStatus) ||
182
+ (input.expectedVersion &&
183
+ job.updatedAt.toISOString() !== input.expectedVersion) ||
184
+ (input.expectedAttempt !== undefined &&
185
+ (attempt || 0) !== input.expectedAttempt)
186
+ ) {
187
+ throw new Error("任务状态已变化");
188
+ }
142
189
  const [activeLease] = await tx
143
190
  .select({ id: durableJobLeases.id })
144
191
  .from(durableJobLeases)
@@ -2,6 +2,7 @@ import type {
2
2
  ClaimedDurableJob,
3
3
  DurableJobArtifact,
4
4
  DurableJobRecovery,
5
+ DurableJobStatus,
5
6
  DurableLeaseIdentity,
6
7
  } from "@southwind-ai/jobs";
7
8
 
@@ -32,6 +33,9 @@ export interface ManualJobRecovery {
32
33
  requestedBy: string;
33
34
  reason: string;
34
35
  idempotencyKey: string;
36
+ expectedAttempt?: number;
37
+ expectedStatus?: DurableJobStatus;
38
+ expectedVersion?: string;
35
39
  now: Date;
36
40
  }
37
41
 
@@ -40,6 +44,17 @@ export interface ManualJobRecoveryResult {
40
44
  recovered: boolean;
41
45
  }
42
46
 
47
+ export interface CancelDurableJob {
48
+ jobId: string;
49
+ expectedStatus?: DurableJobStatus;
50
+ expectedVersion?: string;
51
+ now: Date;
52
+ }
53
+
54
+ export type DurableJobControlResult =
55
+ | { outcome: "changed" | "unchanged" }
56
+ | { outcome: "not-found" | "conflict" };
57
+
43
58
  export interface DurableExecutionRepository {
44
59
  claim(input: ClaimDurableJob): Promise<ClaimedDurableJob | undefined>;
45
60
  heartbeat(
@@ -66,7 +81,9 @@ export interface DurableExecutionRepository {
66
81
  failure: FailDurableJob,
67
82
  now: Date,
68
83
  ): Promise<LeaseResult>;
84
+ /** @deprecated 兼容平台内部旧调用;模块 Port 必须使用 CAS 输入。 */
69
85
  requestCancel(jobId: string, now: Date): Promise<boolean>;
86
+ requestCancel(input: CancelDurableJob): Promise<DurableJobControlResult>;
70
87
  sweepExpired(now: Date, retryDelayMs: number): Promise<number>;
71
88
  recover(input: ManualJobRecovery): Promise<ManualJobRecoveryResult>;
72
89
  }
@@ -0,0 +1,135 @@
1
+ import type {
2
+ DurableJob,
3
+ DurableJobLease,
4
+ DurableJobRecovery,
5
+ DurableJobStage,
6
+ } from "@southwind-ai/jobs";
7
+ import type {
8
+ CancelDurableJob,
9
+ ManualJobRecovery,
10
+ } from "./execution-repository.js";
11
+
12
+ interface InMemoryControlFacts {
13
+ jobs: Map<string, DurableJob>;
14
+ stages: Map<string, DurableJobStage>;
15
+ leases: Map<string, DurableJobLease>;
16
+ recoveries: Map<string, DurableJobRecovery>;
17
+ }
18
+
19
+ export function requestInMemoryCancel(
20
+ facts: InMemoryControlFacts,
21
+ input: CancelDurableJob,
22
+ ) {
23
+ const job = facts.jobs.get(input.jobId);
24
+ if (!job) return { outcome: "not-found" } as const;
25
+ if (job.status === "cancelled" || job.cancelRequestedAt) {
26
+ return { outcome: "unchanged" } as const;
27
+ }
28
+ if (
29
+ ["succeeded", "failed"].includes(job.status) ||
30
+ (input.expectedStatus && job.status !== input.expectedStatus) ||
31
+ (input.expectedVersion && job.updatedAt !== input.expectedVersion)
32
+ ) {
33
+ return { outcome: "conflict" } as const;
34
+ }
35
+ if (job.status === "queued") cancelInMemoryJob(facts, job, input.now);
36
+ else {
37
+ job.cancelRequestedAt = input.now.toISOString();
38
+ job.updatedAt = input.now.toISOString();
39
+ }
40
+ return { outcome: "changed" } as const;
41
+ }
42
+
43
+ export function recoverInMemoryJob(
44
+ facts: InMemoryControlFacts,
45
+ input: ManualJobRecovery,
46
+ ) {
47
+ const existing = [...facts.recoveries.values()].find(
48
+ (item) =>
49
+ item.jobId === input.jobId &&
50
+ item.idempotencyKey === input.idempotencyKey,
51
+ );
52
+ if (existing)
53
+ return { recovery: structuredClone(existing), recovered: false };
54
+ const job = facts.jobs.get(input.jobId);
55
+ if (!job) throw new Error("任务不存在");
56
+ const stages = jobStages(facts, job.id);
57
+ const currentAttempt = Math.max(
58
+ 0,
59
+ ...stages.map((stage) => stage.currentAttempt),
60
+ );
61
+ if (
62
+ (input.expectedStatus && job.status !== input.expectedStatus) ||
63
+ (input.expectedVersion && job.updatedAt !== input.expectedVersion) ||
64
+ (input.expectedAttempt !== undefined &&
65
+ currentAttempt !== input.expectedAttempt)
66
+ )
67
+ throw new Error("任务状态已变化");
68
+ if (!["failed", "cancelled"].includes(job.status))
69
+ throw new Error("只有失败或已取消任务可以人工恢复");
70
+ if (
71
+ [...facts.leases.values()].some(
72
+ (lease) => lease.jobId === job.id && lease.status === "active",
73
+ )
74
+ )
75
+ throw new Error("任务仍有活跃租约,不能人工恢复");
76
+ const recovery: DurableJobRecovery = {
77
+ id: Bun.randomUUIDv7(),
78
+ jobId: job.id,
79
+ fromStatus: job.status,
80
+ requestedBy: input.requestedBy,
81
+ reason: input.reason,
82
+ idempotencyKey: input.idempotencyKey,
83
+ createdAt: input.now.toISOString(),
84
+ };
85
+ facts.recoveries.set(recovery.id, recovery);
86
+ for (const stage of stages) {
87
+ if (stage.status !== "succeeded") {
88
+ Object.assign(stage, {
89
+ status: "pending",
90
+ startedAt: undefined,
91
+ finishedAt: undefined,
92
+ updatedAt: input.now.toISOString(),
93
+ });
94
+ }
95
+ }
96
+ Object.assign(job, {
97
+ status: "queued",
98
+ availableAt: input.now.toISOString(),
99
+ cancelRequestedAt: undefined,
100
+ finishedAt: undefined,
101
+ updatedAt: input.now.toISOString(),
102
+ });
103
+ return { recovery: structuredClone(recovery), recovered: true };
104
+ }
105
+
106
+ export function cancelInMemoryJob(
107
+ facts: Pick<InMemoryControlFacts, "stages">,
108
+ job: DurableJob,
109
+ now: Date,
110
+ ) {
111
+ for (const stage of jobStages(facts, job.id)) {
112
+ if (stage.status !== "succeeded") {
113
+ Object.assign(stage, {
114
+ status: "cancelled",
115
+ finishedAt: now.toISOString(),
116
+ updatedAt: now.toISOString(),
117
+ });
118
+ }
119
+ }
120
+ Object.assign(job, {
121
+ status: "cancelled",
122
+ cancelRequestedAt: job.cancelRequestedAt || now.toISOString(),
123
+ finishedAt: now.toISOString(),
124
+ updatedAt: now.toISOString(),
125
+ });
126
+ }
127
+
128
+ function jobStages(
129
+ facts: Pick<InMemoryControlFacts, "stages">,
130
+ jobId: string,
131
+ ) {
132
+ return [...facts.stages.values()]
133
+ .filter((stage) => stage.jobId === jobId)
134
+ .sort((left, right) => left.ordinal - right.ordinal);
135
+ }
@@ -1,5 +1,7 @@
1
1
  import type {
2
+ CancelDurableJob,
2
3
  ClaimDurableJob,
4
+ DurableJobControlResult,
3
5
  DurableExecutionRepository,
4
6
  FailDurableJob,
5
7
  ManualJobRecovery,
@@ -17,6 +19,11 @@ import {
17
19
  publishInMemoryArtifact,
18
20
  validateInMemoryLease,
19
21
  } from "./in-memory-lease.js";
22
+ import {
23
+ cancelInMemoryJob,
24
+ recoverInMemoryJob,
25
+ requestInMemoryCancel,
26
+ } from "./in-memory-control.js";
20
27
 
21
28
  export class InMemoryDurableExecutionRepository
22
29
  extends InMemoryDurableJobRepository
@@ -160,17 +167,22 @@ export class InMemoryDurableExecutionRepository
160
167
  );
161
168
  }
162
169
 
163
- async requestCancel(jobId: string, now: Date) {
164
- const job = this.jobs.get(jobId);
165
- if (!job || ["succeeded", "failed", "cancelled"].includes(job.status)) {
166
- return false;
167
- }
168
- if (job.status === "queued") this.cancelJob(job, now);
169
- else {
170
- job.cancelRequestedAt = now.toISOString();
171
- job.updatedAt = now.toISOString();
172
- }
173
- return true;
170
+ requestCancel(
171
+ input: CancelDurableJob,
172
+ ): Promise<DurableJobControlResult>;
173
+ requestCancel(jobId: string, now: Date): Promise<boolean>;
174
+ async requestCancel(
175
+ input: CancelDurableJob | string,
176
+ now?: Date,
177
+ ) {
178
+ const legacy = typeof input === "string";
179
+ const result = requestInMemoryCancel(
180
+ this.controlFacts(),
181
+ legacy ? { jobId: input, now: now! } : input,
182
+ );
183
+ return legacy
184
+ ? ["changed", "unchanged"].includes(result.outcome)
185
+ : result;
174
186
  }
175
187
 
176
188
  async sweepExpired(now: Date, retryDelayMs: number) {
@@ -200,45 +212,7 @@ export class InMemoryDurableExecutionRepository
200
212
  }
201
213
 
202
214
  async recover(input: ManualJobRecovery) {
203
- const existing = [...this.recoveries.values()].find(
204
- (item) =>
205
- item.jobId === input.jobId &&
206
- item.idempotencyKey === input.idempotencyKey,
207
- );
208
- if (existing)
209
- return { recovery: structuredClone(existing), recovered: false };
210
- const job = this.jobs.get(input.jobId);
211
- if (!job || !["failed", "cancelled"].includes(job.status)) {
212
- throw new Error("只有失败或已取消任务可以人工恢复");
213
- }
214
- const recovery = {
215
- id: Bun.randomUUIDv7(),
216
- jobId: job.id,
217
- fromStatus: job.status,
218
- requestedBy: input.requestedBy,
219
- reason: input.reason,
220
- idempotencyKey: input.idempotencyKey,
221
- createdAt: input.now.toISOString(),
222
- } as const;
223
- this.recoveries.set(recovery.id, recovery);
224
- for (const stage of this.jobStages(job.id)) {
225
- if (stage.status !== "succeeded") {
226
- Object.assign(stage, {
227
- status: "pending",
228
- startedAt: undefined,
229
- finishedAt: undefined,
230
- updatedAt: input.now.toISOString(),
231
- });
232
- }
233
- }
234
- Object.assign(job, {
235
- status: "queued",
236
- availableAt: input.now.toISOString(),
237
- cancelRequestedAt: undefined,
238
- finishedAt: undefined,
239
- updatedAt: input.now.toISOString(),
240
- });
241
- return { recovery: structuredClone(recovery), recovered: true };
215
+ return recoverInMemoryJob(this.controlFacts(), input);
242
216
  }
243
217
 
244
218
  private settleFailure(
@@ -317,20 +291,15 @@ export class InMemoryDurableExecutionRepository
317
291
  }
318
292
 
319
293
  private cancelJob(job: DurableJob, now: Date) {
320
- for (const stage of this.jobStages(job.id)) {
321
- if (stage.status !== "succeeded") {
322
- Object.assign(stage, {
323
- status: "cancelled",
324
- finishedAt: now.toISOString(),
325
- updatedAt: now.toISOString(),
326
- });
327
- }
328
- }
329
- Object.assign(job, {
330
- status: "cancelled",
331
- cancelRequestedAt: job.cancelRequestedAt || now.toISOString(),
332
- finishedAt: now.toISOString(),
333
- updatedAt: now.toISOString(),
334
- });
294
+ cancelInMemoryJob({ stages: this.stages }, job, now);
295
+ }
296
+
297
+ private controlFacts() {
298
+ return {
299
+ jobs: this.jobs,
300
+ stages: this.stages,
301
+ leases: this.leases,
302
+ recoveries: this.recoveries,
303
+ };
335
304
  }
336
305
  }
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ CancelDurableJob,
2
3
  DurableExecutionRepository,
3
4
  LeaseResult,
4
5
  ManualJobRecovery,
@@ -35,6 +36,7 @@ export class DurableJobWorker {
35
36
  private readonly retryDelayMs: number;
36
37
  private readonly now: () => Date;
37
38
  private readonly contextualize?: DurableWorkerOptions["contextualize"];
39
+ private readonly activeRuns = new Map<string, AbortController>();
38
40
 
39
41
  constructor(
40
42
  private readonly repository: DurableExecutionRepository,
@@ -72,8 +74,20 @@ export class DurableJobWorker {
72
74
  return this.execute(claim, handler);
73
75
  }
74
76
 
75
- cancel(jobId: string) {
76
- return this.repository.requestCancel(jobId, this.now());
77
+ async cancel(jobId: string) {
78
+ const result = await this.controlCancel({ jobId });
79
+ return ["changed", "unchanged"].includes(result.outcome);
80
+ }
81
+
82
+ async controlCancel(input: Omit<CancelDurableJob, "now">) {
83
+ const result = await this.repository.requestCancel({
84
+ ...input,
85
+ now: this.now(),
86
+ });
87
+ if (["changed", "unchanged"].includes(result.outcome)) {
88
+ this.activeRuns.get(input.jobId)?.abort("cancelled");
89
+ }
90
+ return result;
77
91
  }
78
92
 
79
93
  recover(input: Omit<ManualJobRecovery, "now">) {
@@ -91,6 +105,7 @@ export class DurableJobWorker {
91
105
  handler: RegisteredDurableHandler,
92
106
  ): Promise<DurableRunResult> {
93
107
  const abort = new AbortController();
108
+ this.activeRuns.set(claim.jobId, abort);
94
109
  const identity = identityOf(claim);
95
110
  let rejectControl: (error: Error) => void = () => undefined;
96
111
  const control = new Promise<never>((_, reject) => {
@@ -181,7 +196,13 @@ export class DurableJobWorker {
181
196
  if (error instanceof LeaseLostError) {
182
197
  return { status: "lease-lost", jobId: claim.jobId };
183
198
  }
184
- const failure = classifyFailure(error, this.now(), this.retryDelayMs);
199
+ const failure = classifyFailure(
200
+ abort.signal.reason === "cancelled"
201
+ ? new JobCancelledError("任务已请求取消")
202
+ : error,
203
+ this.now(),
204
+ this.retryDelayMs,
205
+ );
185
206
  const result = await this.repository.fail(identity, failure, this.now());
186
207
  if (!result.ok) return { status: "lease-lost", jobId: claim.jobId };
187
208
  return {
@@ -189,6 +210,9 @@ export class DurableJobWorker {
189
210
  jobId: claim.jobId,
190
211
  };
191
212
  } finally {
213
+ if (this.activeRuns.get(claim.jobId) === abort) {
214
+ this.activeRuns.delete(claim.jobId);
215
+ }
192
216
  clearInterval(interval);
193
217
  clearTimeout(timeout);
194
218
  }
@@ -22,6 +22,19 @@ export function exportTarget(
22
22
  fieldKeys: policy.fields.map(({ fieldKey }) => fieldKey),
23
23
  approvalRequired: registration.export?.approvalRequired,
24
24
  retentionSeconds: registration.export?.retentionSeconds,
25
+ ...(registration.export?.renderers
26
+ ? {
27
+ renderers: registration.export.renderers.map((renderer) => ({
28
+ formatKey: renderer.formatKey,
29
+ templateKey: renderer.templateKey,
30
+ templateVersion: renderer.templateVersion,
31
+ inputSchemaKey: renderer.inputSchemaKey,
32
+ contentType: renderer.contentType,
33
+ filenameExtension: renderer.filenameExtension,
34
+ ...renderer.limits,
35
+ })),
36
+ }
37
+ : {}),
25
38
  };
26
39
  }
27
40
 
@@ -29,16 +42,23 @@ export function parseCreate(
29
42
  body: unknown,
30
43
  registration: ModuleGovernedResourceRegistration,
31
44
  ) {
32
- const value = strictObject(body, ["idempotencyKey", "reason", "query"]);
45
+ const value = strictObject(body, [
46
+ "idempotencyKey",
47
+ "reason",
48
+ "query",
49
+ "formatKey",
50
+ ]);
33
51
  try {
34
52
  const query = registration.export!.validateQuery(value.query ?? {});
35
53
  if (!isJsonValue(query) || JSON.stringify(query).length > 64_000) {
36
54
  throw new Error("invalid query");
37
55
  }
56
+ const formatKey = optionalString(value.formatKey);
38
57
  return {
39
58
  idempotencyKey: requiredString(value.idempotencyKey),
40
59
  reason: requiredString(value.reason),
41
60
  query,
61
+ ...(formatKey === undefined ? {} : { formatKey }),
42
62
  };
43
63
  } catch (error) {
44
64
  if (error instanceof GovernedExportServiceError) throw error;
@@ -115,6 +135,9 @@ export function project(request: GovernedExportRequest) {
115
135
  expiresAt: request.expiresAt,
116
136
  createdAt: request.createdAt,
117
137
  updatedAt: request.updatedAt,
138
+ formatKey: request.renderer.formatKey,
139
+ templateKey: request.renderer.templateKey,
140
+ templateVersion: request.renderer.templateVersion,
118
141
  };
119
142
  }
120
143
 
@@ -146,7 +169,3 @@ export function requirePolicy(
146
169
  export function stripApiPrefix(path: string) {
147
170
  return path.slice("/api".length);
148
171
  }
149
-
150
- export function filename(resourceType: string) {
151
- return `governed-${resourceType.replaceAll(/[^a-zA-Z0-9_-]/g, "-")}.ndjson`;
152
- }