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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-windy",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "创建单组织、单租户、私有部署优先的 Windy 企业 Dashboard Starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "templateCommit": "57da45729531a977f6cf1018797d2e1f2afb4e41",
4
- "generatorVersion": "0.3.4",
3
+ "templateCommit": "5be88a688b54b87cf58a753535efb875ee3058e4",
4
+ "generatorVersion": "0.3.6",
5
5
  "modules": [
6
6
  {
7
7
  "name": "system",
@@ -47,6 +47,10 @@
47
47
  - 定制 404、500、维护中或网络异常页时,只修改 project-owned 的
48
48
  `apps/web/src/app/error-pages.ts`,并把业务 SFC 放在 `apps/web/src/app/`;
49
49
  保留四个类型化注册项,不要为换视觉修改 Router、HTTP 核心或平台默认错误页。
50
+ - 新增业务管理页面时,在业务 Module Manifest 注册 Feature、Permission、菜单和
51
+ Admin Route,再把 Vue 页面绑定到 project-owned 的
52
+ `apps/web/src/app/business-routes.ts`;不要修改 Router 或 AppSidebar。导航来自
53
+ 服务端 Manifest 快照,接入见 `docs/development/business-web-routes.md`。
50
54
  - 业务 Agent Operation 开启 Provider 出网时,必须在 Manifest 同时声明最小
51
55
  `providerDataPolicy.inputFields` JSON Pointer allowlist 与
52
56
  `envelopeFields`;raw input 会在进入 `prepare` 前裁剪并执行 DLP。接入与升级
@@ -60,9 +60,9 @@ Skill 完成该能力”。Skill 只指导开发流程;Feature、Permission、
60
60
  运行时事实仍以 Module Manifest 与平台 Registry 为准。
61
61
 
62
62
  当前组合未包含 `system.agent`,MCP Server、Agent Tool Host 和相关文档已被
63
- 物理裁剪。若项目尚可重新生成,请在创建时增加 `--module system.agent` 或使用
64
- `--all-modules`;部署后的通用 `module add` 尚未交付,不要手工复制 MCP Adapter
65
- 或另建授权链。仓库 Skill 仍可用于普通业务模块开发。
63
+ 物理裁剪。创建时可增加 `--module system.agent` 或使用 `--all-modules`;已部署
64
+ 项目使用 `bun run windy module add system.agent`,不要手工复制 MCP Adapter 或
65
+ 另建授权链。仓库 Skill 仍可用于普通业务模块开发。
66
66
 
67
67
 
68
68
 
@@ -99,8 +99,8 @@ create-windy 在本机 `.env` 中随机生成的 `WINDY_BOOTSTRAP_ADMIN_PASSWORD
99
99
  | 法律许可 | `UNLICENSED` |
100
100
 
101
101
  平台当前注册的全部模块如下。H0 表示 profile 锁定核心;H2 表示创建时可以物理包含或
102
- 排除,并已有生成、类型检查、测试和构建证据。H2 不代表项目部署后已经支持无损安装、
103
- 升级或卸载;那需要 H3 的生命周期命令与数据处置证据。
102
+ 排除;H3 还具备部署期安装、更新、数据保留卸载和重装证据。通用生命周期命令会保守
103
+ 处理所有目录模块,但只有通过完整 H3 回归的模块才标记为 H3。
104
104
 
105
105
  | Module Key | 当前项目 | 热插拔可靠度 | 说明 |
106
106
  | ---------- | -------- | ------------ | ---- |
@@ -119,14 +119,17 @@ create-windy 在本机 `.env` 中随机生成的 `WINDY_BOOTSTRAP_ADMIN_PASSWORD
119
119
  | `system.bulk-data` | 未包含 | H2 | 批量导入导出与结果留存。 |
120
120
  | `system.search` | 未包含 | H2 | 权限感知搜索与 Provider 聚合。 |
121
121
  | `system.data-governance` | 未包含 | H2 | 敏感字段、明文访问和治理导出。 |
122
- | `work-order` | 未包含 | H2 | 可替换的业务模块接入示例。 |
122
+ | `work-order` | 未包含 | H3 | 可替换的业务模块接入示例。 |
123
123
 
124
124
  项目自有业务模块以 `apps/server/src/installed-business-modules.ts` 装配的 Module Manifest
125
- 为准;shared-scaffold README 不复制项目业务 Key,避免平台升级固化产品命名。
125
+ 为准;对应 Vue 页面在 project-owned 的
126
+ `apps/web/src/app/business-routes.ts` 注册,侧栏继续从服务端 Manifest 快照生成。
127
+ 不要为增加业务页面修改 shared-scaffold Router 或 AppSidebar。shared-scaffold README
128
+ 不复制项目业务 Key,避免平台升级固化产品命名。
126
129
 
127
130
  精确生成来源和选择记录在 `.windy/generation.json`。当前版本已提供可回滚的
128
- `windy update`;通用 `module add/remove` 尚未交付,不要把 Feature Flag 关闭当成
129
- 模块已经卸载。
131
+ `windy update` 与 `windy module list/add/update/remove`。Feature Flag 关闭仍不
132
+ 等于模块卸载。
130
133
 
131
134
  ## 常用命令
132
135
 
@@ -142,6 +145,9 @@ bun run build
142
145
  bun run windy doctor
143
146
  bun run windy update --check
144
147
  bun run windy update --dry-run
148
+ bun run windy module list
149
+ bun run windy module add work-order --dry-run
150
+ bun run windy module remove work-order --dry-run
145
151
  bun run windy reconcile --to <精确目标版本>
146
152
  bun run lint
147
153
  bun run format
@@ -178,8 +184,9 @@ create-windy 默认建立 `main` 分支并创建 `chore: initialize project` 首
178
184
  没有配置 Git 用户身份,文件会保留在暂存区;配置身份后手动提交即可。
179
185
 
180
186
  当前已交付的 Windy 更新器会根据 Project Lock 和文件所有权,从 npm 历史 artifact
181
- 重建 Base 并执行三方合并;它不连接私有源仓库,也不会静默覆盖项目代码。部署期通用
182
- `module add/update/remove` 尚未交付。
187
+ 重建 Base 并执行三方合并;它不连接私有源仓库,也不会静默覆盖项目代码。模块
188
+ 生命周期命令复用同一计划、事务、验证与恢复边界;卸载默认保留数据库数据,并要求
189
+ 显式确认数据处置计划。
183
190
 
184
191
  ## License
185
192
 
@@ -199,6 +199,20 @@ export async function createApplicationServices(
199
199
  resolveCurrentSystemActor(input.systemRepositories, actorId),
200
200
  );
201
201
  });
202
+ const moduleGovernedExportRenderers = input.moduleHosts.governedResources.flatMap(
203
+ ({ moduleName, registration }) => {
204
+ const policy = input.moduleHosts
205
+ .manifestOf(moduleName)
206
+ .dataPolicies?.find(({ key }) => key === registration.policyKey);
207
+ if (!policy) {
208
+ throw new Error(`模块数据治理策略不存在:${registration.policyKey}`);
209
+ }
210
+ return (registration.export?.renderers ?? []).map((renderer) => ({
211
+ resourceType: policy.resourceType,
212
+ renderer,
213
+ }));
214
+ },
215
+ );
202
216
  const governedExports = new GovernedExportJobRuntime(
203
217
  governedExportRequests,
204
218
  services.durable.service,
@@ -224,6 +238,8 @@ export async function createApplicationServices(
224
238
  features: input.foundation.features,
225
239
  runtime: input.runtime,
226
240
  }),
241
+ undefined,
242
+ moduleGovernedExportRenderers,
227
243
  );
228
244
  const governedExportService = new GovernedExportService(
229
245
  governedExportRequests,
@@ -9,7 +9,10 @@ import { createServerRuntime } from "../../runtime.js";
9
9
  import { createSystemRepositories } from "../../system/seed.js";
10
10
  import { SystemGovernedExportAuthorization } from "./authorization-port.js";
11
11
  import { captureGovernedExportScope } from "./scope-snapshot.js";
12
- import type { GovernedExportRequest } from "./types.js";
12
+ import {
13
+ legacyGovernedExportRendererSnapshot,
14
+ type GovernedExportRequest,
15
+ } from "./types.js";
13
16
 
14
17
  const actor: RequestActor = {
15
18
  id: "test-bootstrap",
@@ -109,6 +112,7 @@ function exportRequest(
109
112
  scopeSnapshot,
110
113
  scopeHash,
111
114
  querySnapshot: { includeDeleted: false },
115
+ renderer: legacyGovernedExportRendererSnapshot,
112
116
  status: "queued",
113
117
  approvalRequired: false,
114
118
  expiresAt: "2027-01-01T00:00:00.000Z",
@@ -4,6 +4,7 @@ import { drizzle } from "drizzle-orm/node-postgres";
4
4
  import { migrate } from "drizzle-orm/node-postgres/migrator";
5
5
  import { Pool } from "pg";
6
6
  import { DrizzleGovernedExportRepository } from "./drizzle-repository.js";
7
+ import { legacyGovernedExportRendererSnapshot } from "./types.js";
7
8
 
8
9
  const databaseUrl = process.env.GOVERNED_EXPORT_TEST_DATABASE_URL;
9
10
  const describePostgres = databaseUrl ? describe : describe.skip;
@@ -49,6 +50,7 @@ describePostgres("PostgreSQL governed export repository", () => {
49
50
  },
50
51
  scopeHash: "a".repeat(64),
51
52
  querySnapshot: { includeDeleted: false },
53
+ renderer: legacyGovernedExportRendererSnapshot,
52
54
  approvalRequired: false,
53
55
  expiresAt: "2026-07-17T02:00:00.000Z",
54
56
  };
@@ -86,6 +86,7 @@ function mapRequest(row: Row): GovernedExportRequest {
86
86
  fieldKeys: [...row.fieldKeys],
87
87
  scopeSnapshot: structuredClone(row.scopeSnapshot),
88
88
  querySnapshot: structuredClone(row.querySnapshot),
89
+ renderer: structuredClone(row.renderer),
89
90
  status: row.status as GovernedExportRequest["status"],
90
91
  approvalRequired: row.approvalRequired,
91
92
  approvalId: row.approvalId || undefined,
@@ -15,6 +15,7 @@ import type { GovernedExportAuthorizationPort } from "./authorization-port.js";
15
15
  import { InMemoryGovernedExportRepository } from "./repository.js";
16
16
  import { captureGovernedExportScope } from "./scope-snapshot.js";
17
17
  import type { GovernedExportSource } from "./source.js";
18
+ import { legacyGovernedExportRendererSnapshot } from "./types.js";
18
19
 
19
20
  const base = new Date("2026-07-16T03:00:00.000Z");
20
21
  const canary = "sensitive-canary-13800138000";
@@ -271,8 +272,8 @@ describe("governed export job runtime", () => {
271
272
  }),
272
273
  ]);
273
274
  });
274
- });
275
275
 
276
+ });
276
277
  async function createFixture(
277
278
  auditWriter?: AuditEventWriter,
278
279
  blobStore: BlobStore = new MemoryBlobStore(),
@@ -320,6 +321,7 @@ async function createFixture(
320
321
  scopeSnapshot: scope.snapshot,
321
322
  scopeHash: scope.hash,
322
323
  querySnapshot: { includeDeleted: false },
324
+ renderer: legacyGovernedExportRendererSnapshot,
323
325
  approvalRequired: false,
324
326
  expiresAt: "2026-07-17T03:00:00.000Z",
325
327
  },
@@ -8,8 +8,11 @@ import type { DurableJobService } from "../../jobs/service.js";
8
8
  import type { DurableJobWorker } from "../../jobs/worker.js";
9
9
  import type { GovernedExportRepository } from "./repository.js";
10
10
  import type { GovernedExportSource } from "./source.js";
11
- import { createWatermarkedNdjsonStream } from "./ndjson-stream.js";
12
11
  import type { GovernedExportRequest } from "./types.js";
12
+ import {
13
+ createGovernedExportRenderSession,
14
+ type GovernedExportRendererBinding,
15
+ } from "./renderer-runtime.js";
13
16
  import {
14
17
  allowGovernedExportExecution,
15
18
  type GovernedExportAuthorizationDecision,
@@ -33,6 +36,7 @@ export class GovernedExportJobRuntime {
33
36
  ) => Promise<boolean> = async (request) => !request.approvalRequired,
34
37
  private readonly authorization: GovernedExportAuthorizationPort = allowGovernedExportExecution,
35
38
  private readonly now: () => Date = () => new Date(),
39
+ private readonly renderers: readonly GovernedExportRendererBinding[] = [],
36
40
  ) {
37
41
  registry.register({
38
42
  key: handlerKey,
@@ -132,20 +136,17 @@ export class GovernedExportJobRuntime {
132
136
  } catch {
133
137
  throw safeFailure("EXPORT_AUDIT_UNAVAILABLE", true);
134
138
  }
135
- let stored;
139
+ let stored: Awaited<ReturnType<BlobStore["put"]>> | undefined;
140
+ let session;
136
141
  try {
137
- stored = await this.blobStore.put({
138
- body: createWatermarkedNdjsonStream({
142
+ session = await createGovernedExportRenderSession(
143
+ {
139
144
  request,
140
145
  source,
141
146
  signal: context.signal,
142
- watermark: {
143
- actorId: request.requesterId,
144
- generatedAt,
145
- scopeHash: request.scopeHash,
146
- jobId: context.jobId,
147
- requestId: request.id,
148
- },
147
+ jobId: context.jobId,
148
+ generatedAt,
149
+ heartbeat: context.heartbeat,
149
150
  beforePage: async () => {
150
151
  const authorization = await this.authorization.evaluate(request);
151
152
  if (!authorization.allowed) {
@@ -156,22 +157,43 @@ export class GovernedExportJobRuntime {
156
157
  await context.heartbeat();
157
158
  await context.progress({ completedUnits: completed });
158
159
  },
159
- }),
160
+ },
161
+ this.renderers,
162
+ );
163
+ stored = await this.blobStore.put({
164
+ body: session.body,
160
165
  });
166
+ session.assertRecordsConsumed();
161
167
  } catch (error) {
168
+ if (stored) await this.blobStore.delete(stored.ref);
162
169
  if (error instanceof DurableHandlerError) throw error;
170
+ if (context.signal.aborted) throw error;
171
+ throw safeFailure("EXPORT_STREAM_FAILED", isRetryableRenderFailure(error));
172
+ } finally {
173
+ session?.dispose();
174
+ }
175
+ if (!stored || !session) {
163
176
  throw safeFailure("EXPORT_STREAM_FAILED", true);
164
177
  }
165
178
  try {
166
179
  await context.publishArtifact({
167
180
  kind: "governed-export",
168
181
  artifactRef: stored.ref,
169
- filename: `${safeFilename(request.resourceType)}.ndjson`,
170
- contentType: "application/x-ndjson",
182
+ filename: `${safeFilename(request.resourceType)}.${request.renderer.filenameExtension}`,
183
+ contentType: request.renderer.contentType,
171
184
  byteSize: stored.byteSize,
172
185
  sha256: stored.contentHash.value,
173
186
  ownerId: request.requesterId,
174
187
  expiresAt: request.expiresAt,
188
+ metadata: {
189
+ formatKey: request.renderer.formatKey,
190
+ templateKey: request.renderer.templateKey,
191
+ templateVersion: request.renderer.templateVersion,
192
+ inputSchemaKey: request.renderer.inputSchemaKey,
193
+ ...(session?.contentMetadata
194
+ ? { contentMetadata: session.contentMetadata }
195
+ : {}),
196
+ },
175
197
  });
176
198
  } catch (error) {
177
199
  await this.blobStore.delete(stored.ref);
@@ -220,6 +242,11 @@ function generationIntentEvent(
220
242
  metadata: {
221
243
  policyKey: request.policyKey,
222
244
  policyVersion: request.policyVersion,
245
+ formatKey: request.renderer.formatKey,
246
+ templateKey: request.renderer.templateKey,
247
+ templateVersion: request.renderer.templateVersion,
248
+ inputSchemaKey: request.renderer.inputSchemaKey,
249
+ contentType: request.renderer.contentType,
223
250
  scopeHash: request.scopeHash,
224
251
  jobId,
225
252
  phase: "intent",
@@ -228,6 +255,18 @@ function generationIntentEvent(
228
255
  };
229
256
  }
230
257
 
258
+ function isRetryableRenderFailure(error: unknown) {
259
+ const code = error instanceof Error ? error.message : "";
260
+ return ![
261
+ "EXPORT_RENDERER_NOT_REGISTERED",
262
+ "EXPORT_RENDERER_VERSION_DRIFT",
263
+ "EXPORT_RECORD_LIMIT_EXCEEDED",
264
+ "EXPORT_OUTPUT_LIMIT_EXCEEDED",
265
+ "EXPORT_RECORDS_NOT_CONSUMED",
266
+ "EXPORT_CONTENT_METADATA_TOO_LARGE",
267
+ ].includes(code);
268
+ }
269
+
231
270
  function authorizationDeniedEvent(
232
271
  request: GovernedExportRequest,
233
272
  decision: Extract<GovernedExportAuthorizationDecision, { allowed: false }>,
@@ -14,6 +14,7 @@ export function createWatermarkedNdjsonStream(input: {
14
14
  source: GovernedExportSource;
15
15
  watermark: ExportWatermark;
16
16
  signal: AbortSignal;
17
+ maxRecords?: number;
17
18
  beforePage(): Promise<void>;
18
19
  pageCompleted(completed: number): Promise<void>;
19
20
  }) {
@@ -43,6 +44,7 @@ async function* lines(input: {
43
44
  source: GovernedExportSource;
44
45
  watermark: ExportWatermark;
45
46
  signal: AbortSignal;
47
+ maxRecords?: number;
46
48
  beforePage(): Promise<void>;
47
49
  pageCompleted(completed: number): Promise<void>;
48
50
  }) {
@@ -62,6 +64,12 @@ async function* lines(input: {
62
64
  if (result.done) return;
63
65
  if (input.signal.aborted) throw new Error("EXPORT_ABORTED");
64
66
  for (const record of result.value) {
67
+ if (
68
+ input.maxRecords !== undefined &&
69
+ completed >= input.maxRecords
70
+ ) {
71
+ throw new Error("EXPORT_RECORD_LIMIT_EXCEEDED");
72
+ }
65
73
  yield { type: "record", watermark: input.watermark, data: record };
66
74
  completed += 1;
67
75
  }
@@ -21,6 +21,7 @@ import {
21
21
  createPostgresApiFixture,
22
22
  ownerCanaries,
23
23
  } from "./postgres-api-fixture.js";
24
+ import { legacyGovernedExportRendererSnapshot } from "./types.js";
24
25
  import { captureGovernedExportScope } from "./scope-snapshot.js";
25
26
  import type { GovernedExportSource } from "./source.js";
26
27
 
@@ -202,6 +203,7 @@ async function exerciseFailureRetry(
202
203
  scopeSnapshot: scope.snapshot,
203
204
  scopeHash: scope.hash,
204
205
  querySnapshot: { includeDeleted: false },
206
+ renderer: legacyGovernedExportRendererSnapshot,
205
207
  approvalRequired: false,
206
208
  expiresAt: new Date(
207
209
  fixture.clock.current.getTime() + 86_400_000,
@@ -0,0 +1,235 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DurableHandlerRegistry } from "@southwind-ai/jobs";
3
+ import type { ModuleGovernedExportRendererRegistration } from "@southwind-ai/server-sdk";
4
+ import { MemoryBlobStore } from "@southwind-ai/storage";
5
+ import { buildScopeContext } from "../../data-access/context.js";
6
+ import { InMemoryDurableExecutionRepository } from "../../jobs/in-memory-execution.js";
7
+ import { DurableJobService } from "../../jobs/service.js";
8
+ import { DurableJobWorker } from "../../jobs/worker.js";
9
+ import { createServerRuntime } from "../../runtime.js";
10
+ import { GovernedExportJobRuntime } from "./job-runtime.js";
11
+ import { InMemoryGovernedExportRepository } from "./repository.js";
12
+ import { captureGovernedExportScope } from "./scope-snapshot.js";
13
+ import type { GovernedExportSource } from "./source.js";
14
+ import type { GovernedExportRendererSnapshot } from "./types.js";
15
+
16
+ const now = new Date("2026-07-27T01:00:00.000Z");
17
+
18
+ describe("governed export renderer runtime", () => {
19
+ test("Renderer 消费逐页记录与水印并发布受控格式、模板和内容元数据", async () => {
20
+ for (const [formatKey, contentType] of formats) {
21
+ const renderer = reportRenderer(formatKey, contentType, 1024);
22
+ const fixture = await setup(renderer, true);
23
+ const job = await fixture.runtime.submit(fixture.request);
24
+
25
+ expect(await fixture.runtime.runNext(`${formatKey}-worker`)).toMatchObject({
26
+ status: "succeeded",
27
+ });
28
+ const artifact = (await fixture.jobs.get(job.id))?.artifacts[0];
29
+ if (!artifact) throw new Error("Renderer artifact 未发布");
30
+ expect(artifact).toMatchObject({
31
+ filename: `governed-people-report.${formatKey}`,
32
+ contentType,
33
+ metadata: {
34
+ formatKey,
35
+ templateKey: `reports.official.${formatKey}`,
36
+ templateVersion: "2026.07",
37
+ inputSchemaKey: "reports.official.input.v1",
38
+ contentMetadata: { sourceCount: 2 },
39
+ },
40
+ });
41
+ const blob = await fixture.blobs.open(artifact.artifactRef);
42
+ if (!blob) throw new Error("Renderer Blob 不存在");
43
+ const content = await new Response(blob.body).text();
44
+ expect(content).toContain("source:evidence-1");
45
+ expect(content).toContain(fixture.request.scopeHash);
46
+ expect(content).toContain(job.id);
47
+ expect(fixture.authorizationChecks()).toBe(4);
48
+ }
49
+ });
50
+
51
+ test("未绑定 Renderer 与输出超限 fail closed 且不保留半成品", async () => {
52
+ const renderer = reportRenderer("pdf", "application/pdf", 4);
53
+ for (const registered of [false, true]) {
54
+ const fixture = await setup(renderer, registered);
55
+ const job = await fixture.runtime.submit(fixture.request);
56
+ expect(await fixture.runtime.runNext("failed-renderer-worker")).toMatchObject(
57
+ { status: "failed" },
58
+ );
59
+ expect(fixture.blobs.size).toBe(0);
60
+ expect((await fixture.jobs.get(job.id))?.artifacts).toEqual([]);
61
+ }
62
+ });
63
+
64
+ test("Durable Job 取消经 heartbeat 传播到 Renderer AbortSignal", async () => {
65
+ let enter: () => void = () => undefined;
66
+ let resume: () => void = () => undefined;
67
+ const entered = new Promise<void>((resolve) => {
68
+ enter = resolve;
69
+ });
70
+ const resumed = new Promise<void>((resolve) => {
71
+ resume = resolve;
72
+ });
73
+ let aborted = false;
74
+ const renderer: ModuleGovernedExportRendererRegistration = {
75
+ ...reportRenderer("pdf", "application/pdf", 1024),
76
+ async render(_input, context) {
77
+ enter();
78
+ await resumed;
79
+ try {
80
+ await context.heartbeat();
81
+ } catch (error) {
82
+ aborted = context.signal.aborted;
83
+ throw error;
84
+ }
85
+ throw new Error("取消未传播");
86
+ },
87
+ };
88
+ const fixture = await setup(renderer, true);
89
+ const job = await fixture.runtime.submit(fixture.request);
90
+ const running = fixture.runtime.runNext("cancel-renderer-worker");
91
+ await entered;
92
+ await fixture.runtime.cancel(job.id);
93
+ resume();
94
+
95
+ expect(await running).toMatchObject({ status: "cancelled" });
96
+ expect(aborted).toBe(true);
97
+ expect(fixture.blobs.size).toBe(0);
98
+ expect((await fixture.jobs.get(job.id))?.artifacts).toEqual([]);
99
+ });
100
+ });
101
+
102
+ async function setup(
103
+ renderer: ModuleGovernedExportRendererRegistration,
104
+ registered: boolean,
105
+ ) {
106
+ const requests = new InMemoryGovernedExportRepository();
107
+ const facts = new InMemoryDurableExecutionRepository();
108
+ const jobs = new DurableJobService(facts, () => now);
109
+ const registry = new DurableHandlerRegistry();
110
+ const worker = new DurableJobWorker(facts, registry, { now: () => now });
111
+ const blobs = new MemoryBlobStore();
112
+ const server = createServerRuntime();
113
+ let authorizationChecks = 0;
114
+ const source: GovernedExportSource = {
115
+ resourceType: "people.report",
116
+ async *pages() {
117
+ yield [{ id: "evidence-1", reference: "source-a" }];
118
+ yield [{ id: "evidence-2", reference: "source-b" }];
119
+ },
120
+ };
121
+ const scope = captureGovernedExportScope(
122
+ buildScopeContext("reviewer-1", "dept-1", [
123
+ {
124
+ id: "role-reviewer",
125
+ code: "reviewer",
126
+ dataScope: "department",
127
+ departmentIds: [],
128
+ },
129
+ ]),
130
+ );
131
+ const created = await requests.create(
132
+ {
133
+ id: Bun.randomUUIDv7(),
134
+ resourceType: "people.report",
135
+ policyKey: "people.report.fields",
136
+ policyVersion: "1",
137
+ fieldKeys: ["content"],
138
+ requesterId: "reviewer-1",
139
+ requesterName: "复核员",
140
+ idempotencyKey: Bun.randomUUIDv7(),
141
+ requestFingerprint: "a".repeat(64),
142
+ reason: "生成正式复核报告",
143
+ scopeSnapshot: scope.snapshot,
144
+ scopeHash: scope.hash,
145
+ querySnapshot: {},
146
+ renderer: snapshot(renderer),
147
+ approvalRequired: true,
148
+ approvalId: "approval-1",
149
+ expiresAt: "2026-07-28T01:00:00.000Z",
150
+ },
151
+ now,
152
+ );
153
+ return {
154
+ request: created.request,
155
+ runtime: new GovernedExportJobRuntime(
156
+ requests,
157
+ jobs,
158
+ worker,
159
+ registry,
160
+ blobs,
161
+ server.auditSink,
162
+ [source],
163
+ async () => true,
164
+ {
165
+ evaluate: async () => {
166
+ authorizationChecks += 1;
167
+ return { allowed: true };
168
+ },
169
+ },
170
+ () => now,
171
+ registered ? [{ resourceType: source.resourceType, renderer }] : [],
172
+ ),
173
+ jobs,
174
+ blobs,
175
+ authorizationChecks: () => authorizationChecks,
176
+ };
177
+ }
178
+
179
+ function reportRenderer(
180
+ formatKey: string,
181
+ contentType: string,
182
+ maxOutputBytes: number,
183
+ ): ModuleGovernedExportRendererRegistration {
184
+ return {
185
+ formatKey,
186
+ templateKey: `reports.official.${formatKey}`,
187
+ templateVersion: "2026.07",
188
+ inputSchemaKey: "reports.official.input.v1",
189
+ contentType,
190
+ filenameExtension: formatKey,
191
+ limits: { maxRecords: 10, maxOutputBytes, timeoutSeconds: 10 },
192
+ async render(input) {
193
+ const sources: string[] = [];
194
+ for await (const record of input.records) {
195
+ sources.push(`source:${String(record.id)}`);
196
+ }
197
+ return {
198
+ body: new Response(
199
+ [
200
+ `watermark:${input.watermark.scopeHash}:${input.watermark.jobId}`,
201
+ ...sources,
202
+ ].join("\n"),
203
+ ).body!,
204
+ contentMetadata: { sourceCount: sources.length },
205
+ };
206
+ },
207
+ };
208
+ }
209
+
210
+ const formats = [
211
+ ["ndjson", "application/x-ndjson"],
212
+ ["pdf", "application/pdf"],
213
+ [
214
+ "docx",
215
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
216
+ ],
217
+ [
218
+ "xlsx",
219
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
220
+ ],
221
+ ] as const;
222
+
223
+ function snapshot(
224
+ renderer: ModuleGovernedExportRendererRegistration,
225
+ ): GovernedExportRendererSnapshot {
226
+ return {
227
+ formatKey: renderer.formatKey,
228
+ templateKey: renderer.templateKey,
229
+ templateVersion: renderer.templateVersion,
230
+ inputSchemaKey: renderer.inputSchemaKey,
231
+ contentType: renderer.contentType,
232
+ filenameExtension: renderer.filenameExtension,
233
+ ...renderer.limits,
234
+ };
235
+ }