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.
- package/README.md +20 -3
- package/dist/cli.js +585 -111
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +4 -0
- package/template/README.md +18 -11
- package/template/apps/server/src/application-services.ts +16 -0
- package/template/apps/server/src/data-governance/export/authorization-port.test.ts +5 -1
- package/template/apps/server/src/data-governance/export/drizzle-repository.integration.test.ts +2 -0
- package/template/apps/server/src/data-governance/export/drizzle-repository.ts +1 -0
- package/template/apps/server/src/data-governance/export/job-runtime.test.ts +3 -1
- package/template/apps/server/src/data-governance/export/job-runtime.ts +53 -14
- package/template/apps/server/src/data-governance/export/ndjson-stream.ts +8 -0
- package/template/apps/server/src/data-governance/export/postgres-recovery.integration.test.ts +2 -0
- package/template/apps/server/src/data-governance/export/renderer-job-runtime.test.ts +235 -0
- package/template/apps/server/src/data-governance/export/renderer-runtime.ts +293 -0
- package/template/apps/server/src/data-governance/export/renderer-selection.test.ts +67 -0
- package/template/apps/server/src/data-governance/export/repository.test.ts +2 -0
- package/template/apps/server/src/data-governance/export/request-fingerprint.test.ts +13 -0
- package/template/apps/server/src/data-governance/export/request-fingerprint.ts +2 -0
- package/template/apps/server/src/data-governance/export/routes.ts +11 -1
- package/template/apps/server/src/data-governance/export/service-support.ts +61 -4
- package/template/apps/server/src/data-governance/export/service.ts +8 -2
- package/template/apps/server/src/data-governance/export/types.ts +27 -0
- package/template/apps/server/src/index.ts +1 -0
- package/template/apps/server/src/jobs/artifact-service.test.ts +14 -2
- package/template/apps/server/src/jobs/artifact-service.ts +10 -0
- package/template/apps/server/src/jobs/drizzle-execution-repository.ts +17 -18
- package/template/apps/server/src/jobs/drizzle-execution.integration.test.ts +19 -1
- package/template/apps/server/src/jobs/drizzle-mappers.ts +3 -1
- package/template/apps/server/src/jobs/drizzle-recovery-repository.ts +49 -2
- package/template/apps/server/src/jobs/execution-repository.ts +17 -0
- package/template/apps/server/src/jobs/in-memory-control.ts +135 -0
- package/template/apps/server/src/jobs/in-memory-execution.ts +34 -65
- package/template/apps/server/src/jobs/worker.ts +27 -3
- package/template/apps/server/src/module-host/governed-export-route-support.ts +24 -5
- package/template/apps/server/src/module-host/governed-export-routes.test.ts +5 -1
- package/template/apps/server/src/module-host/governed-export-routes.ts +1 -2
- package/template/apps/server/src/module-host/governed-renderer-registration.test.ts +247 -0
- package/template/apps/server/src/module-host/governed-resources.test.ts +1 -0
- package/template/apps/server/src/module-host/governed-resources.ts +56 -0
- package/template/apps/server/src/module-host/http-runtime.ts +3 -0
- package/template/apps/server/src/module-host/job-port.test.ts +262 -0
- package/template/apps/server/src/module-host/job-port.ts +334 -0
- package/template/apps/server/src/module-host/request-context.ts +14 -26
- package/template/apps/server/src/module-host/route-installer.ts +11 -0
- package/template/apps/web/src/app/business-routes.ts +22 -0
- package/template/apps/web/src/components/common/AccessSnapshotBoundary.vue +112 -0
- package/template/apps/web/src/components/common/AccessSnapshotBoundary.webtest.ts +130 -0
- package/template/apps/web/src/components/common/AccessSnapshotNotice.vue +117 -0
- package/template/apps/web/src/composables/access-snapshot-change.ts +131 -0
- package/template/apps/web/src/composables/access-snapshot-change.webtest.ts +103 -0
- package/template/apps/web/src/composables/useAccessSnapshot.ts +48 -2
- package/template/apps/web/src/composables/useAccessSnapshot.webtest.ts +54 -0
- package/template/apps/web/src/composables/useAccessSnapshotMonitor.ts +42 -0
- package/template/apps/web/src/layout/AppShell.vue +28 -25
- package/template/apps/web/src/layout/AppShell.webtest.ts +1 -0
- package/template/apps/web/src/router/business-routes.ts +68 -0
- package/template/apps/web/src/router/business-routes.webtest.ts +85 -0
- package/template/apps/web/src/router/index.ts +7 -0
- package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +12 -0
- package/template/apps/web/src/services/access-api.ts +5 -1
- package/template/apps/web/src/services/access-api.webtest.ts +35 -0
- package/template/docs/architecture/ai-egress.md +12 -9
- package/template/docs/architecture/durable-jobs.md +7 -1
- package/template/docs/architecture/foundation-infrastructure.md +163 -0
- package/template/docs/architecture/module-host.md +115 -0
- package/template/docs/architecture/server-module-interface.md +39 -0
- package/template/docs/architecture/web-access-snapshot-resilience.md +60 -0
- package/template/docs/development/business-web-routes.md +66 -0
- package/template/docs/development/custom-login-page.md +3 -3
- package/template/docs/development/module-lifecycle.md +71 -0
- package/template/docs/operations/observability.md +5 -1
- package/template/docs/platform/agent-runtime.md +6 -6
- package/template/docs/platform/agent-tool-sandbox.md +58 -0
- package/template/docs/platform/document-ai-runtime.md +204 -0
- package/template/docs/platform/governed-export-renderers.md +132 -0
- package/template/docs/platform/navigation-registry.md +5 -0
- package/template/docs/platform/organization-membership.md +2 -1
- package/template/docs/platform/speech-ai-runtime.md +156 -0
- package/template/docs/platform/system-crud-api.md +7 -1
- package/template/docs/platform/vision-ai-runtime.md +97 -0
- package/template/docs/platform/web-system-crud.md +6 -0
- package/template/packages/config/package.json +1 -1
- package/template/packages/database/drizzle/0042_puzzling_rumiko_fujikawa.sql +27 -0
- package/template/packages/database/drizzle/meta/0042_snapshot.json +8541 -0
- package/template/packages/database/drizzle/meta/_journal.json +7 -0
- package/template/packages/database/package.json +1 -1
- package/template/packages/database/src/schema/durable-jobs.ts +1 -0
- package/template/packages/database/src/schema/governed-exports.ts +15 -0
- package/template/packages/database/src/schema-governed-exports.test.ts +21 -1
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs/src/handlers.ts +1 -0
- package/template/packages/jobs/src/types.ts +1 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/server-sdk/index.ts +11 -0
- package/template/packages/server-sdk/package.json +1 -1
- package/template/packages/server-sdk/src/data-governance-registration.ts +46 -0
- package/template/packages/server-sdk/src/jobs-port.ts +69 -0
- package/template/packages/server-sdk/src/request-context.ts +2 -2
- package/template/packages/shared/package.json +1 -1
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -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。接入与升级
|
package/template/README.md
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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` | 未包含 |
|
|
122
|
+
| `work-order` | 未包含 | H3 | 可替换的业务模块接入示例。 |
|
|
123
123
|
|
|
124
124
|
项目自有业务模块以 `apps/server/src/installed-business-modules.ts` 装配的 Module Manifest
|
|
125
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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",
|
package/template/apps/server/src/data-governance/export/drizzle-repository.integration.test.ts
CHANGED
|
@@ -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
|
-
|
|
138
|
-
|
|
142
|
+
session = await createGovernedExportRenderSession(
|
|
143
|
+
{
|
|
139
144
|
request,
|
|
140
145
|
source,
|
|
141
146
|
signal: context.signal,
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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)}.
|
|
170
|
-
contentType:
|
|
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
|
}
|
package/template/apps/server/src/data-governance/export/postgres-recovery.integration.test.ts
CHANGED
|
@@ -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
|
+
}
|