create-windy 0.3.4 → 0.3.5

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 +582 -109
  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
@@ -4,7 +4,10 @@ import type { ModuleGovernedResourceRegistration } from "@southwind-ai/server-sd
4
4
  import { featureKey, type PermissionDefinition } from "@southwind-ai/shared";
5
5
  import { captureGovernedExportScope } from "../data-governance/export/scope-snapshot.js";
6
6
  import { ModuleGovernedExportSource } from "../data-governance/export/source.js";
7
- import type { GovernedExportRequest } from "../data-governance/export/types.js";
7
+ import {
8
+ legacyGovernedExportRendererSnapshot,
9
+ type GovernedExportRequest,
10
+ } from "../data-governance/export/types.js";
8
11
  import { buildActorScopeContexts } from "../data-access/context.js";
9
12
  import type { RequestActor } from "../guards.js";
10
13
  import { createServerRuntime } from "../runtime.js";
@@ -314,6 +317,7 @@ function requestFixture(): GovernedExportRequest {
314
317
  scopeSnapshot: scope.snapshot,
315
318
  scopeHash: scope.hash,
316
319
  querySnapshot: { caseId: "case-1" },
320
+ renderer: legacyGovernedExportRendererSnapshot,
317
321
  status: "pending-approval",
318
322
  approvalRequired: true,
319
323
  expiresAt: "2099-01-01T00:00:00.000Z",
@@ -14,7 +14,6 @@ import {
14
14
  import type { InitializedModuleHosts } from "./initialize.js";
15
15
  import {
16
16
  exportTarget,
17
- filename,
18
17
  knownError,
19
18
  optionalString,
20
19
  parseCreate,
@@ -194,7 +193,7 @@ function installDownloadRoute(
194
193
  headers: {
195
194
  "cache-control": "no-store",
196
195
  "content-type": opened.artifact.contentType,
197
- "content-disposition": `attachment; filename="${filename(policy.resourceType)}"`,
196
+ "content-disposition": `attachment; filename="${opened.artifact.filename}"`,
198
197
  "x-content-sha256": opened.artifact.sha256,
199
198
  },
200
199
  });
@@ -0,0 +1,247 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ModuleManifest } from "@southwind-ai/modules";
3
+ import type {
4
+ ModuleGovernedExportRegistration,
5
+ ModuleGovernedResourceRegistration,
6
+ } from "@southwind-ai/server-sdk";
7
+ import { featureKey } from "@southwind-ai/shared";
8
+ import { initializeModuleHosts } from "./initialize.js";
9
+ import { manifest } from "./test-fixtures.js";
10
+
11
+ describe("业务模块治理导出 Renderer 注册", () => {
12
+ test("同一资源可注册 NDJSON、PDF、DOCX 与 XLSX", async () => {
13
+ const declared = exportManifest();
14
+ const hosts = await initializeModuleHosts({
15
+ modules: [declared],
16
+ installations: [
17
+ {
18
+ manifest: declared,
19
+ setup(host) {
20
+ host.registerGovernedResources([resource()]);
21
+ },
22
+ },
23
+ ],
24
+ });
25
+ expect(
26
+ hosts.governedResources[0]?.registration.export?.renderers?.map(
27
+ ({ formatKey }) => formatKey,
28
+ ),
29
+ ).toEqual(["ndjson", "pdf", "docx", "xlsx"]);
30
+ });
31
+
32
+ test("重复格式、未知 Schema 和越界 limits 在监听前 fail closed", async () => {
33
+ await expectInvalid(
34
+ (configured) => ({
35
+ ...configured,
36
+ renderers: [
37
+ configured.renderers![0]!,
38
+ { ...configured.renderers![0]!, templateVersion: "2" },
39
+ ],
40
+ }),
41
+ "formatKey 无效或重复",
42
+ );
43
+ await expectInvalid(
44
+ (configured) => ({
45
+ ...configured,
46
+ renderers: [
47
+ {
48
+ ...configured.renderers![0]!,
49
+ inputSchemaKey: "reports.missing.v1",
50
+ },
51
+ ],
52
+ }),
53
+ "未注册 JSON Schema",
54
+ );
55
+ await expectInvalid(
56
+ (configured) => ({
57
+ ...configured,
58
+ renderers: [
59
+ {
60
+ ...configured.renderers![0]!,
61
+ limits: {
62
+ ...configured.renderers![0]!.limits,
63
+ timeoutSeconds: 901,
64
+ },
65
+ },
66
+ ],
67
+ }),
68
+ "元数据或上限无效",
69
+ );
70
+ });
71
+ });
72
+
73
+ async function expectInvalid(
74
+ change: (
75
+ configured: ModuleGovernedExportRegistration,
76
+ ) => ModuleGovernedExportRegistration,
77
+ message: string,
78
+ ) {
79
+ const declared = exportManifest();
80
+ await expect(
81
+ initializeModuleHosts({
82
+ modules: [declared],
83
+ installations: [
84
+ {
85
+ manifest: declared,
86
+ setup(host) {
87
+ const base = resource();
88
+ host.registerGovernedResources([
89
+ { ...base, export: change(base.export!) },
90
+ ]);
91
+ },
92
+ },
93
+ ],
94
+ }),
95
+ ).rejects.toThrow(message);
96
+ }
97
+
98
+ function resource(): ModuleGovernedResourceRegistration {
99
+ return {
100
+ policyKey: "reports.official.fields",
101
+ readPath: "/api/reports/official/:id",
102
+ revealPath: "/api/reports/official/:id/plaintext",
103
+ revealAuditAction: "reports.official.reveal",
104
+ authorize: () => true,
105
+ read: () => undefined,
106
+ export: exportRegistration(),
107
+ };
108
+ }
109
+
110
+ function exportRegistration(): ModuleGovernedExportRegistration {
111
+ const formats = [
112
+ ["ndjson", "application/x-ndjson"],
113
+ ["pdf", "application/pdf"],
114
+ [
115
+ "docx",
116
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
117
+ ],
118
+ [
119
+ "xlsx",
120
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
121
+ ],
122
+ ] as const;
123
+ return {
124
+ basePath: "/api/reports/official/exports",
125
+ validateQuery: () => ({}),
126
+ readPage: () => ({ records: [] }),
127
+ renderers: formats.map(([formatKey, contentType]) => ({
128
+ formatKey,
129
+ templateKey: `reports.official.${formatKey}`,
130
+ templateVersion: "1.0.0",
131
+ inputSchemaKey: "reports.official.input.v1",
132
+ contentType,
133
+ filenameExtension: formatKey,
134
+ limits: {
135
+ maxRecords: 10_000,
136
+ maxOutputBytes: 20 * 1024 * 1024,
137
+ timeoutSeconds: 120,
138
+ },
139
+ render: () => ({ body: new ReadableStream<Uint8Array>() }),
140
+ })),
141
+ };
142
+ }
143
+
144
+ function exportManifest(): ModuleManifest {
145
+ const feature = featureKey("reports.official");
146
+ const declared = manifest("reports", {
147
+ permissions: [
148
+ permission("reports.official.read", "standard"),
149
+ permission("reports.official.reveal", "sensitive-disclosure"),
150
+ permission("reports.official.export", "governed-export"),
151
+ ],
152
+ features: [
153
+ {
154
+ key: feature,
155
+ label: "正式报告",
156
+ module: "reports",
157
+ visible: "visible",
158
+ enabled: true,
159
+ stage: "stable",
160
+ },
161
+ ],
162
+ jsonSchemas: [
163
+ {
164
+ key: "reports.official.input.v1",
165
+ schema: { type: "object", properties: {} },
166
+ },
167
+ ],
168
+ dataPolicies: [
169
+ {
170
+ key: "reports.official.fields",
171
+ resourceType: "reports.official",
172
+ version: "1",
173
+ featureKey: feature,
174
+ readPermissionKey: "reports.official.read",
175
+ revealPermissionKey: "reports.official.reveal",
176
+ exportPermissionKey: "reports.official.export",
177
+ display: {
178
+ read: "masked",
179
+ reveal: "plaintext",
180
+ unauthorized: "hidden",
181
+ },
182
+ fields: [
183
+ {
184
+ fieldKey: "content",
185
+ classification: "sensitive",
186
+ mask: { kind: "redact" },
187
+ },
188
+ ],
189
+ },
190
+ ],
191
+ auditActions: ["reports.official.reveal"],
192
+ });
193
+ declared.apiPermissions = [
194
+ route("GET", "/api/reports/official/:id", "reports.official.read", feature),
195
+ route(
196
+ "POST",
197
+ "/api/reports/official/:id/plaintext",
198
+ "reports.official.reveal",
199
+ feature,
200
+ ),
201
+ ...(
202
+ [
203
+ ["POST", ""],
204
+ ["GET", "/:id"],
205
+ ["POST", "/:id/cancel"],
206
+ ["GET", "/:id/download"],
207
+ ] as const
208
+ ).map(([method, suffix]) => {
209
+ return route(
210
+ method,
211
+ `/api/reports/official/exports${suffix}`,
212
+ "reports.official.export",
213
+ feature,
214
+ );
215
+ }),
216
+ ];
217
+ return declared;
218
+ }
219
+
220
+ function permission(
221
+ key: string,
222
+ accessRisk: "standard" | "sensitive-disclosure" | "governed-export",
223
+ ) {
224
+ return {
225
+ key,
226
+ label: key,
227
+ module: "reports",
228
+ moduleLabel: "报告",
229
+ resource: "official",
230
+ resourceLabel: "正式报告",
231
+ action: key.endsWith(".read")
232
+ ? ("read" as const)
233
+ : key.endsWith(".reveal")
234
+ ? ("reveal" as const)
235
+ : ("export" as const),
236
+ accessRisk,
237
+ };
238
+ }
239
+
240
+ function route(
241
+ method: "GET" | "POST",
242
+ path: string,
243
+ permissionKey: string,
244
+ feature: ReturnType<typeof featureKey>,
245
+ ) {
246
+ return { method, path, permissionKey, featureKey: feature };
247
+ }
@@ -122,6 +122,7 @@ describe("业务模块数据治理 Port", () => {
122
122
  }),
123
123
  ).rejects.toThrow("业务治理资源重复注册");
124
124
  });
125
+
125
126
  });
126
127
 
127
128
  interface SetupOptions {
@@ -94,6 +94,7 @@ function validateExportRegistration(
94
94
  ) {
95
95
  throw new Error(`业务治理导出保留时间无效:${registration.policyKey}`);
96
96
  }
97
+ validateRenderers(manifest, registration);
97
98
  for (const [method, suffix] of [
98
99
  ["POST", ""],
99
100
  ["GET", "/:id"],
@@ -112,6 +113,61 @@ function validateExportRegistration(
112
113
  }
113
114
  }
114
115
 
116
+ function validateRenderers(
117
+ manifest: ModuleManifest,
118
+ registration: ModuleGovernedResourceRegistration,
119
+ ) {
120
+ const renderers = registration.export?.renderers;
121
+ if (!renderers) return;
122
+ if (renderers.length === 0) {
123
+ throw new Error(`业务治理导出 Renderer 列表不能为空:${registration.policyKey}`);
124
+ }
125
+ const formatKeys = new Set<string>();
126
+ const schemas = new Set(
127
+ (manifest.jsonSchemas ?? []).map(({ key }) => key),
128
+ );
129
+ for (const renderer of renderers) {
130
+ if (
131
+ !/^[a-z0-9][a-z0-9._-]{0,79}$/.test(renderer.formatKey) ||
132
+ formatKeys.has(renderer.formatKey)
133
+ ) {
134
+ throw new Error(
135
+ `业务治理导出 formatKey 无效或重复:${renderer.formatKey}`,
136
+ );
137
+ }
138
+ formatKeys.add(renderer.formatKey);
139
+ requireModuleNamespace("治理导出模板", renderer.templateKey, manifest.name);
140
+ if (!schemas.has(renderer.inputSchemaKey)) {
141
+ throw new Error(
142
+ `业务治理导出引用了未注册 JSON Schema:${renderer.inputSchemaKey}`,
143
+ );
144
+ }
145
+ if (
146
+ !renderer.templateVersion.trim() ||
147
+ renderer.templateVersion.length > 80 ||
148
+ !/^[a-z0-9][a-z0-9.+-]{0,79}$/i.test(renderer.templateVersion) ||
149
+ renderer.contentType.length > 120 ||
150
+ !/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i.test(
151
+ renderer.contentType,
152
+ ) ||
153
+ !/^[a-z0-9]{1,16}$/.test(renderer.filenameExtension) ||
154
+ !Number.isInteger(renderer.limits.maxRecords) ||
155
+ renderer.limits.maxRecords < 1 ||
156
+ renderer.limits.maxRecords > 1_000_000 ||
157
+ !Number.isInteger(renderer.limits.maxOutputBytes) ||
158
+ renderer.limits.maxOutputBytes < 1 ||
159
+ renderer.limits.maxOutputBytes > 1024 * 1024 * 1024 ||
160
+ !Number.isInteger(renderer.limits.timeoutSeconds) ||
161
+ renderer.limits.timeoutSeconds < 1 ||
162
+ renderer.limits.timeoutSeconds > 900
163
+ ) {
164
+ throw new Error(
165
+ `业务治理导出 Renderer 元数据或上限无效:${renderer.formatKey}`,
166
+ );
167
+ }
168
+ }
169
+ }
170
+
115
171
  function validateRoute(
116
172
  manifest: ModuleManifest,
117
173
  state: GovernedResourceRegistrationState,
@@ -1,6 +1,7 @@
1
1
  import type { FeatureFlagDefinition } from "@southwind-ai/shared";
2
2
  // @windy-module system.scheduler begin
3
3
  import type { DurableJobService } from "../jobs/service.js";
4
+ import type { DurableJobWorker } from "../jobs/worker.js";
4
5
  // @windy-module system.scheduler end
5
6
  import type { createServerRuntime } from "../runtime.js";
6
7
  // @windy-module system.storage begin
@@ -31,6 +32,7 @@ export function installModuleHttpRuntime(
31
32
  features: readonly FeatureFlagDefinition[];
32
33
  // @windy-module system.scheduler begin
33
34
  jobs: DurableJobService;
35
+ jobWorker: DurableJobWorker;
34
36
  // @windy-module system.scheduler end
35
37
  // @windy-module system.storage begin
36
38
  storage: ReturnType<typeof createStorageRuntime>;
@@ -55,6 +57,7 @@ export function installModuleHttpRuntime(
55
57
  features: context.features,
56
58
  // @windy-module system.scheduler begin
57
59
  jobs: context.jobs,
60
+ jobControl: { worker: context.jobWorker },
58
61
  // @windy-module system.scheduler end
59
62
  // @windy-module system.storage begin
60
63
  storage: {
@@ -0,0 +1,262 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DurableHandlerRegistry } from "@southwind-ai/jobs";
3
+ import { buildActorScopeContexts } from "../data-access/context.js";
4
+ import type { RequestActor } from "../guards.js";
5
+ import { InMemoryDurableExecutionRepository } from "../jobs/in-memory-execution.js";
6
+ import { DurableJobService } from "../jobs/service.js";
7
+ import { DurableJobWorker } from "../jobs/worker.js";
8
+ import { createServerRuntime } from "../runtime.js";
9
+ import { createModuleJobPort } from "./job-port.js";
10
+
11
+ const now = new Date("2026-07-27T08:00:00.000Z");
12
+
13
+ describe("Module Durable Job 受限控制 Port", () => {
14
+ test("最小投影限制在当前 Module、Actor、Scope 与当前授权", async () => {
15
+ const fixture = createFixture();
16
+ const submitted = await fixture.port.submit({
17
+ handlerKey: "evidence.analyze",
18
+ idempotencyKey: "analysis-1",
19
+ payload: { privateTranscript: "不得返回" },
20
+ });
21
+
22
+ expect(await fixture.port.get(submitted.jobId)).toEqual(
23
+ expect.objectContaining({
24
+ jobId: submitted.jobId,
25
+ handlerKey: "evidence.analyze",
26
+ status: "queued",
27
+ attempt: 0,
28
+ canCancel: true,
29
+ }),
30
+ );
31
+ expect(
32
+ JSON.stringify(await fixture.port.get(submitted.jobId)),
33
+ ).not.toContain("privateTranscript");
34
+ expect(
35
+ await createFixture({
36
+ facts: fixture.repository,
37
+ moduleName: "other",
38
+ }).port.get(submitted.jobId),
39
+ ).toBeUndefined();
40
+ expect(
41
+ await createFixture({
42
+ facts: fixture.repository,
43
+ actorId: "actor-b",
44
+ }).port.get(submitted.jobId),
45
+ ).toBeUndefined();
46
+ await expect(
47
+ createFixture({
48
+ facts: fixture.repository,
49
+ actorId: "actor-b",
50
+ }).port.cancel({
51
+ jobId: submitted.jobId,
52
+ reason: "尝试取消其他操作者任务",
53
+ }),
54
+ ).rejects.toMatchObject({ code: "JOB_NOT_AVAILABLE" });
55
+ await expect(
56
+ fixture.port.cancel({
57
+ jobId: "missing-job",
58
+ reason: "尝试取消不存在的任务",
59
+ }),
60
+ ).rejects.toMatchObject({ code: "JOB_NOT_AVAILABLE" });
61
+ expect(
62
+ await createFixture({
63
+ facts: fixture.repository,
64
+ departmentId: "dept-b",
65
+ }).port.get(submitted.jobId),
66
+ ).toBeUndefined();
67
+ fixture.authorized.value = false;
68
+ expect(await fixture.port.get(submitted.jobId)).toBeUndefined();
69
+ });
70
+
71
+ test("取消使用 CAS、重复调用幂等并将脱敏原因写入关键审计", async () => {
72
+ const fixture = createFixture();
73
+ const submitted = await fixture.port.submit({
74
+ handlerKey: "evidence.analyze",
75
+ idempotencyKey: "cancel-1",
76
+ payload: {},
77
+ });
78
+ const view = await fixture.port.get(submitted.jobId);
79
+ if (!view) throw new Error("任务未创建");
80
+
81
+ const cancelled = await fixture.port.cancel({
82
+ jobId: view.jobId,
83
+ reason: "申请人 test@example.com 要求停止 13800138000",
84
+ expectedStatus: view.status,
85
+ expectedVersion: view.version,
86
+ });
87
+ expect(cancelled.status).toBe("cancelled");
88
+ expect(
89
+ await fixture.port.cancel({
90
+ jobId: view.jobId,
91
+ reason: "申请人 test@example.com 要求停止 13800138000",
92
+ expectedStatus: view.status,
93
+ expectedVersion: view.version,
94
+ }),
95
+ ).toMatchObject({ status: "cancelled" });
96
+ expect(fixture.runtime.auditSink.list()).toContainEqual(
97
+ expect.objectContaining({
98
+ action: "module.job.cancel",
99
+ outcome: "success",
100
+ metadata: expect.objectContaining({
101
+ reason: "申请人 [邮箱] 要求停止 [号码]",
102
+ }),
103
+ }),
104
+ );
105
+ });
106
+
107
+ test("运行中取消立即传播 AbortSignal 并阻止成功完成", async () => {
108
+ const registry = new DurableHandlerRegistry();
109
+ let started: () => void = () => {};
110
+ const startedPromise = new Promise<void>((resolve) => {
111
+ started = () => resolve();
112
+ });
113
+ let observedReason: unknown;
114
+ registry.register(
115
+ {
116
+ key: "evidence.analyze",
117
+ payload: { parse: (value) => value as Record<string, unknown> },
118
+ execute: async (_, context) => {
119
+ started();
120
+ await new Promise<void>((_, reject) => {
121
+ context.signal.addEventListener(
122
+ "abort",
123
+ () => {
124
+ observedReason = context.signal.reason;
125
+ reject(new Error("aborted"));
126
+ },
127
+ { once: true },
128
+ );
129
+ });
130
+ },
131
+ },
132
+ { moduleName: "evidence" },
133
+ );
134
+ const fixture = createFixture({ registry });
135
+ const submitted = await fixture.port.submit({
136
+ handlerKey: "evidence.analyze",
137
+ idempotencyKey: "running-cancel-1",
138
+ payload: {},
139
+ maxAttempts: 1,
140
+ });
141
+ const running = fixture.worker.runNext("evidence", "worker-1");
142
+ await startedPromise;
143
+ const view = await fixture.port.get(submitted.jobId);
144
+ if (!view) throw new Error("运行中任务不可见");
145
+ await fixture.port.cancel({
146
+ jobId: view.jobId,
147
+ reason: "人工确认停止后台研判",
148
+ expectedStatus: view.status,
149
+ expectedVersion: view.version,
150
+ });
151
+
152
+ expect(await running).toEqual({
153
+ status: "cancelled",
154
+ jobId: submitted.jobId,
155
+ });
156
+ expect(observedReason).toBe("cancelled");
157
+ expect((await fixture.facts.get(submitted.jobId))?.job.status).toBe(
158
+ "cancelled",
159
+ );
160
+ });
161
+
162
+ test("人工恢复以 attempt/status/version CAS 保留失败与恢复事实", async () => {
163
+ const fixture = createFixture();
164
+ const submitted = await fixture.port.submit({
165
+ handlerKey: "evidence.analyze",
166
+ idempotencyKey: "recover-1",
167
+ payload: {},
168
+ maxAttempts: 1,
169
+ });
170
+ const claim = await fixture.repository.claim({
171
+ queue: "evidence",
172
+ workerId: "worker-1",
173
+ now,
174
+ leaseDurationMs: 30_000,
175
+ });
176
+ if (!claim) throw new Error("任务未领取");
177
+ await fixture.repository.fail(
178
+ claim,
179
+ { code: "MODEL_UNAVAILABLE", message: "模型不可用", retryable: false },
180
+ now,
181
+ );
182
+ const failed = await fixture.port.get(submitted.jobId);
183
+ if (!failed) throw new Error("失败任务不可见");
184
+ const request = {
185
+ jobId: failed.jobId,
186
+ reason: "人工确认依赖已经恢复",
187
+ expectedAttempt: failed.attempt,
188
+ expectedStatus: failed.status,
189
+ expectedVersion: failed.version,
190
+ } as const;
191
+
192
+ const recovered = await Promise.all([
193
+ fixture.port.recover(request),
194
+ fixture.port.recover(request),
195
+ ]);
196
+ expect(recovered).toEqual([
197
+ expect.objectContaining({ status: "queued", attempt: 1 }),
198
+ expect.objectContaining({ status: "queued", attempt: 1 }),
199
+ ]);
200
+ expect(await fixture.facts.get(submitted.jobId)).toMatchObject({
201
+ attempts: [{ status: "failed" }],
202
+ failures: [{ code: "MODEL_UNAVAILABLE" }],
203
+ recoveries: [
204
+ expect.objectContaining({
205
+ fromStatus: "failed",
206
+ reason: "人工确认依赖已经恢复",
207
+ }),
208
+ ],
209
+ });
210
+ });
211
+ });
212
+
213
+ function createFixture(
214
+ options: {
215
+ facts?: InMemoryDurableExecutionRepository;
216
+ registry?: DurableHandlerRegistry;
217
+ moduleName?: string;
218
+ actorId?: string;
219
+ departmentId?: string;
220
+ } = {},
221
+ ) {
222
+ const moduleName = options.moduleName || "evidence";
223
+ const actorId = options.actorId || "actor-a";
224
+ const departmentId = options.departmentId || "dept-a";
225
+ const repository = options.facts || new InMemoryDurableExecutionRepository();
226
+ const facts = new DurableJobService(repository, () => now);
227
+ const worker = new DurableJobWorker(
228
+ repository,
229
+ options.registry || new DurableHandlerRegistry(),
230
+ { now: () => now, leaseDurationMs: 60_000 },
231
+ );
232
+ const actor: RequestActor = {
233
+ id: actorId,
234
+ type: "user",
235
+ name: actorId,
236
+ roleCodes: ["analyst"],
237
+ permissionKeys: [`${moduleName}.analysis.manage`],
238
+ ...buildActorScopeContexts(actorId, departmentId, [
239
+ {
240
+ id: "role-analyst",
241
+ code: "analyst",
242
+ dataScope: "department",
243
+ departmentIds: [],
244
+ },
245
+ ]),
246
+ };
247
+ const runtime = createServerRuntime();
248
+ const authorized = { value: true };
249
+ const port = createModuleJobPort({
250
+ moduleName,
251
+ guardContext: {
252
+ requestId: `request-${actorId}`,
253
+ actor,
254
+ startedAt: now.toISOString(),
255
+ },
256
+ runtime,
257
+ jobs: facts,
258
+ control: { worker },
259
+ reauthorize: async () => authorized.value,
260
+ });
261
+ return { port, facts, repository, worker, runtime, authorized };
262
+ }