create-windy 0.3.16 → 0.3.18

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 (65) hide show
  1. package/README.md +14 -36
  2. package/dist/cli.js +1831 -3178
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +7 -1
  6. package/template/README.md +8 -11
  7. package/template/apps/agent-server/Dockerfile +2 -1
  8. package/template/apps/agent-server/README.md +13 -5
  9. package/template/apps/agent-server/adk_web_agents/canonical_debug/__init__.py +1 -0
  10. package/template/apps/agent-server/adk_web_agents/canonical_debug/agent.py +125 -0
  11. package/template/apps/agent-server/project/adk-web-debug.json +4 -0
  12. package/template/apps/agent-server/src/southwind_agent_server/adk_debug.py +315 -0
  13. package/template/apps/agent-server/tests/test_adk_web_canonical_debug.py +235 -0
  14. package/template/apps/server/Dockerfile +1 -1
  15. package/template/apps/server/src/agent/run-debug-authorization.ts +27 -0
  16. package/template/apps/server/src/agent/run-facade-adk-debug.test.ts +56 -0
  17. package/template/apps/server/src/agent/run-facade.test-support.ts +235 -0
  18. package/template/apps/server/src/agent/run-facade.test.ts +8 -244
  19. package/template/apps/server/src/agent/run-facade.ts +23 -48
  20. package/template/apps/server/src/agent/run-operation-registry.ts +51 -0
  21. package/template/apps/server/src/agent/runtime-bootstrap.ts +5 -2
  22. package/template/apps/server/src/agent/tool-audit.ts +3 -0
  23. package/template/apps/server/src/agent/tool-registry.test.ts +29 -0
  24. package/template/apps/server/src/application-services.ts +83 -15
  25. package/template/apps/server/src/audit/contracts.ts +8 -0
  26. package/template/apps/server/src/audit/recovery-queue.test.ts +48 -1
  27. package/template/apps/server/src/audit/recovery-queue.ts +73 -4
  28. package/template/apps/server/src/audit/reliable-writer.test.ts +120 -2
  29. package/template/apps/server/src/audit/reliable-writer.ts +98 -34
  30. package/template/apps/server/src/guards.ts +16 -0
  31. package/template/apps/server/src/index.ts +23 -20
  32. package/template/apps/server/src/jobs/legacy-adapter.integration.test.ts +0 -1
  33. package/template/apps/server/src/jobs/legacy-adapter.test.ts +6 -0
  34. package/template/apps/server/src/jobs/legacy-service-runtime.ts +18 -0
  35. package/template/apps/server/src/module-host/durable-context-unavailable.test.ts +84 -0
  36. package/template/apps/server/src/module-host/durable-context.test.ts +14 -0
  37. package/template/apps/server/src/module-host/durable-context.ts +17 -15
  38. package/template/apps/server/src/module-host/request-context.test.ts +30 -1
  39. package/template/apps/server/src/module-host/request-context.ts +15 -2
  40. package/template/apps/server/src/module-host/storage-port.ts +1 -1
  41. package/template/apps/server/src/platform-bootstrap.ts +16 -0
  42. package/template/apps/server/src/runtime-adk-debug.test.ts +246 -0
  43. package/template/apps/server/src/runtime-adk-debug.ts +158 -0
  44. package/template/apps/server/src/runtime-request-actor.test.ts +55 -0
  45. package/template/apps/server/src/runtime-request-actor.ts +22 -0
  46. package/template/apps/server/src/runtime.test.ts +3 -0
  47. package/template/apps/server/src/runtime.ts +36 -3
  48. package/template/apps/server/src/scheduler/definitions.test.ts +16 -0
  49. package/template/apps/web/Dockerfile +1 -1
  50. package/template/docker-compose.agent-debug.yml +56 -0
  51. package/template/docker-compose.yml +1 -39
  52. package/template/docs/architecture/ai-runtime.md +14 -5
  53. package/template/docs/architecture/module-manifest-composition.md +14 -0
  54. package/template/docs/development/custom-login-page.md +2 -16
  55. package/template/docs/platform/agent-runtime.md +57 -16
  56. package/template/docs/platform/agent-tool-sandbox.md +4 -3
  57. package/template/docs/platform/audit-reliability.md +33 -5
  58. package/template/docs/platform/platform-authentication.md +9 -1
  59. package/template/package.json +4 -3
  60. package/template/packages/database/drizzle/0043_activate_agent_dependencies.sql +196 -0
  61. package/template/packages/database/drizzle/meta/0043_snapshot.json +8335 -0
  62. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  63. package/template/packages/database/src/schema-notification-delivery.test.ts +15 -6
  64. package/template/packages/database/src/schema-workflow-notification.test.ts +44 -29
  65. package/template/scripts/dev-adk-web.ts +107 -0
@@ -1,4 +1,7 @@
1
+ // @windy-module system.bulk-data begin
1
2
  import { DictionaryBulkResourceHandler } from "./bulk-data/dictionary-handler.js";
3
+ // @windy-module system.bulk-data end
4
+ // @windy-module system.data-governance begin
2
5
  import { DrizzleGovernedExportRepository } from "./data-governance/export/drizzle-repository.js";
3
6
  import { WorkflowGovernedExportApproval } from "./data-governance/export/approval-port.js";
4
7
  import { GovernedExportJobRuntime } from "./data-governance/export/job-runtime.js";
@@ -9,25 +12,41 @@ import {
9
12
  GovernedUserExportSource,
10
13
  ModuleGovernedExportSource,
11
14
  } from "./data-governance/export/source.js";
15
+ // @windy-module system.data-governance end
16
+ // @windy-module system.bulk-data begin
12
17
  import { DrizzleBulkDataRepository } from "./bulk-data/drizzle-repository.js";
13
18
  import { InMemoryBulkDataRepository } from "./bulk-data/repository.js";
19
+ // @windy-module system.bulk-data end
14
20
  import type { createFoundationSnapshot } from "./foundation.js";
15
21
  import { createLegacyServiceRuntime } from "./jobs/legacy-service-runtime.js";
22
+ // @windy-module system.data-governance begin
16
23
  import { DurableArtifactService } from "./jobs/artifact-service.js";
24
+ // @windy-module system.data-governance end
25
+ // @windy-module system.notification begin
17
26
  import { DrizzleNotificationRepository } from "./notification/drizzle-repository.js";
18
27
  import { InMemoryNotificationRepository } from "./notification/repository.js";
28
+ // @windy-module system.notification end
19
29
  import type { createServerPersistence } from "./persistence.js";
20
30
  import type { createServerRuntime } from "./runtime.js";
21
31
  import { createPlatformTaskDefinitions } from "./scheduler/definitions.js";
22
32
  import { DrizzleSchedulerRepository } from "./scheduler/drizzle-repository.js";
23
33
  import { InMemorySchedulerRepository } from "./scheduler/repository.js";
24
34
  import type { ScheduledTaskDefinition } from "./scheduler/types.js";
35
+ // @windy-module system.storage begin
25
36
  import type { createStorageRuntime } from "./storage/runtime.js";
37
+ // @windy-module system.storage end
38
+ // @windy-module system.notification begin
26
39
  import type { PlatformConfig } from "@southwind-ai/config";
40
+ // @windy-module system.notification end
27
41
  import type { DurableHandlerRegistry } from "@southwind-ai/jobs";
42
+ // @windy-module system.storage begin
28
43
  import { collectActiveBlobRefs } from "./storage/blob-reference-registry.js";
44
+ // @windy-module system.storage end
29
45
  import type { createSystemRepositories } from "./system/seed.js";
46
+ // @windy-module system.data-governance begin
30
47
  import { identityRouteConfigs } from "./system/identity-route-config.js";
48
+ // @windy-module system.data-governance end
49
+ // @windy-module system.workflow begin
31
50
  import { SystemWorkflowAssigneeDirectory } from "./workflow/assignee-directory.js";
32
51
  import { DrizzleWorkflowRepository } from "./workflow/drizzle-repository.js";
33
52
  import {
@@ -37,6 +56,8 @@ import {
37
56
  } from "./workflow/effects.js";
38
57
  import { InMemoryWorkflowRepository } from "./workflow/repository.js";
39
58
  import { WorkflowService } from "./workflow/service.js";
59
+ // @windy-module system.workflow end
60
+ // @windy-module system.notification begin
40
61
  import { NotificationChannelRegistry } from "./notification/channel-adapter.js";
41
62
  import { DrizzleNotificationDeliveryRepository } from "./notification/delivery-repository.js";
42
63
  import { NotificationDeliveryWorker } from "./notification/delivery-worker.js";
@@ -48,6 +69,7 @@ import {
48
69
  import { SystemNotificationRecipientDirectory } from "./notification/recipient-directory.js";
49
70
  import { NotificationService } from "./notification/service.js";
50
71
  import { SmtpNotificationChannelAdapter } from "./notification/smtp-adapter.js";
72
+ // @windy-module system.notification end
51
73
  import type { InitializedModuleHosts } from "./module-host.js";
52
74
  import {
53
75
  createModuleDurableContextualizer,
@@ -60,10 +82,16 @@ interface ApplicationServicesInput {
60
82
  systemRepositories: ReturnType<typeof createSystemRepositories>;
61
83
  foundation: ReturnType<typeof createFoundationSnapshot>;
62
84
  runtime: ReturnType<typeof createServerRuntime>;
85
+ // @windy-module system.storage begin
63
86
  storage: ReturnType<typeof createStorageRuntime>;
87
+ // @windy-module system.storage end
64
88
  moduleTasks: ScheduledTaskDefinition[];
89
+ // @windy-module system.bulk-data begin
65
90
  bulkRetentionDays: number;
91
+ // @windy-module system.bulk-data end
92
+ // @windy-module system.notification begin
66
93
  mailConfig?: PlatformConfig["mail"];
94
+ // @windy-module system.notification end
67
95
  installModuleDurableHandlers?: (registry: DurableHandlerRegistry) => void;
68
96
  moduleHosts: InitializedModuleHosts;
69
97
  // @windy-module system.agent begin
@@ -75,6 +103,7 @@ export async function createApplicationServices(
75
103
  input: ApplicationServicesInput,
76
104
  ) {
77
105
  const { persistence } = input;
106
+ // @windy-module system.notification begin
78
107
  const notifications = persistence
79
108
  ? new DrizzleNotificationRepository(persistence.db)
80
109
  : new InMemoryNotificationRepository();
@@ -104,6 +133,8 @@ export async function createApplicationServices(
104
133
  notificationRecipients,
105
134
  notificationChannels,
106
135
  );
136
+ // @windy-module system.notification end
137
+ // @windy-module system.workflow begin
107
138
  const deferredTimeout = new DeferredWorkflowTimeoutScheduler();
108
139
  const workflow = new WorkflowService(
109
140
  persistence
@@ -116,31 +147,50 @@ export async function createApplicationServices(
116
147
  deferredTimeout,
117
148
  ),
118
149
  );
150
+ // @windy-module system.workflow end
151
+ // @windy-module system.data-governance begin
119
152
  const governedExportDispatch: {
120
153
  runNext?: GovernedExportJobRuntime["runNext"];
121
154
  } = {};
155
+ // @windy-module system.data-governance end
156
+ // @windy-module system.bulk-data begin
122
157
  const bulkRepository = persistence
123
158
  ? new DrizzleBulkDataRepository(persistence.db)
124
159
  : new InMemoryBulkDataRepository();
160
+ // @windy-module system.bulk-data end
125
161
  const taskDefinitions = [
126
162
  ...createPlatformTaskDefinitions(input.foundation.features, {
163
+ // @windy-module system.bulk-data begin
127
164
  bulkDataDispatch: () => services.bulkDataService.processNext(),
128
165
  bulkDataCleanup: () => services.bulkDataService.cleanupExpired(),
166
+ // @windy-module system.bulk-data end
167
+ // @windy-module system.storage begin
129
168
  storageCleanup: async () => {
130
169
  const now = new Date();
131
170
  const referencedRefs = await collectActiveBlobRefs(
132
- [services.durable.facts, bulkRepository],
171
+ [
172
+ services.durable.facts,
173
+ // @windy-module system.bulk-data begin
174
+ bulkRepository,
175
+ // @windy-module system.bulk-data end
176
+ ],
133
177
  now,
134
178
  );
135
179
  return {
136
180
  ...(await input.storage.uploads.cleanupExpired(100, referencedRefs)),
137
181
  };
138
182
  },
183
+ // @windy-module system.storage end
184
+ // @windy-module system.workflow begin
139
185
  workflowTimeoutDispatch: () => services.workflowTimeout.runNext(),
186
+ // @windy-module system.workflow end
187
+ // @windy-module system.data-governance begin
140
188
  governedExportDispatch: async () => {
141
189
  const result = await governedExportDispatch.runNext?.();
142
190
  return { claimed: Boolean(result && result.status !== "idle") };
143
191
  },
192
+ // @windy-module system.data-governance end
193
+ // @windy-module system.notification begin
144
194
  notificationDispatch: async () => {
145
195
  const scheduled = await notificationService.dispatchScheduled();
146
196
  const delivery = await notificationDeliveryWorker.runNext();
@@ -150,29 +200,36 @@ export async function createApplicationServices(
150
200
  delivery,
151
201
  };
152
202
  },
203
+ // @windy-module system.notification end
153
204
  }),
154
205
  ...input.moduleTasks,
155
206
  ];
156
207
  const services = createLegacyServiceRuntime({
157
208
  db: persistence?.db,
209
+ // @windy-module system.bulk-data begin
158
210
  bulkRepository,
159
211
  bulkHandlers: [
160
212
  new DictionaryBulkResourceHandler(input.systemRepositories.dictionaries),
161
213
  ],
162
214
  bulkLimits: { retentionDays: input.bulkRetentionDays },
163
215
  blobStore: input.storage.blobStore,
216
+ // @windy-module system.bulk-data end
164
217
  schedulerRepository: persistence
165
218
  ? new DrizzleSchedulerRepository(persistence.db)
166
219
  : new InMemorySchedulerRepository(),
167
220
  taskDefinitions,
168
221
  runtime: input.runtime,
222
+ // @windy-module system.workflow begin
169
223
  workflow,
224
+ // @windy-module system.workflow end
170
225
  installModuleDurableHandlers: input.installModuleDurableHandlers,
171
226
  durableContextualize: createModuleDurableContextualizer({
172
227
  hosts: input.moduleHosts,
173
228
  runtime: input.runtime,
174
229
  features: input.foundation.features,
230
+ // @windy-module system.storage begin
175
231
  storage: input.storage,
232
+ // @windy-module system.storage end
176
233
  // @windy-module system.agent begin
177
234
  backgroundAi: input.backgroundAi,
178
235
  // @windy-module system.agent end
@@ -180,6 +237,7 @@ export async function createApplicationServices(
180
237
  resolveCurrentSystemActor(input.systemRepositories, actorId),
181
238
  }),
182
239
  });
240
+ // @windy-module system.data-governance begin
183
241
  const governedExportRequests = persistence
184
242
  ? new DrizzleGovernedExportRepository(persistence.db)
185
243
  : new InMemoryGovernedExportRepository();
@@ -199,20 +257,21 @@ export async function createApplicationServices(
199
257
  resolveCurrentSystemActor(input.systemRepositories, actorId),
200
258
  );
201
259
  });
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
- );
260
+ const moduleGovernedExportRenderers =
261
+ input.moduleHosts.governedResources.flatMap(
262
+ ({ moduleName, registration }) => {
263
+ const policy = input.moduleHosts
264
+ .manifestOf(moduleName)
265
+ .dataPolicies?.find(({ key }) => key === registration.policyKey);
266
+ if (!policy) {
267
+ throw new Error(`模块数据治理策略不存在:${registration.policyKey}`);
268
+ }
269
+ return (registration.export?.renderers ?? []).map((renderer) => ({
270
+ resourceType: policy.resourceType,
271
+ renderer,
272
+ }));
273
+ },
274
+ );
216
275
  const governedExports = new GovernedExportJobRuntime(
217
276
  governedExportRequests,
218
277
  services.durable.service,
@@ -251,17 +310,26 @@ export async function createApplicationServices(
251
310
  { approvalRequired: true, retentionSeconds: 24 * 60 * 60 },
252
311
  );
253
312
  governedExportDispatch.runNext = () => governedExports.runNext();
313
+ // @windy-module system.data-governance end
314
+ // @windy-module system.workflow begin
254
315
  deferredTimeout.attach(services.workflowTimeout);
255
316
  await services.workflowTimeout.reconcile();
317
+ // @windy-module system.workflow end
256
318
  await services.scheduler.initialize();
257
319
  return {
258
320
  ...services,
321
+ // @windy-module system.notification begin
259
322
  notifications,
260
323
  notificationService,
261
324
  notificationDeliveryWorker,
325
+ // @windy-module system.notification end
326
+ // @windy-module system.workflow begin
262
327
  workflow,
328
+ // @windy-module system.workflow end
329
+ // @windy-module system.data-governance begin
263
330
  governedExports,
264
331
  governedExportService,
265
332
  governedExportRequests,
333
+ // @windy-module system.data-governance end
266
334
  };
267
335
  }
@@ -1,5 +1,11 @@
1
1
  export type AuditWritePolicy = "ordinary" | "critical";
2
2
 
3
+ export type AuditRecoveryFailure =
4
+ | "permission-denied"
5
+ | "invalid-format"
6
+ | "io-error"
7
+ | "unknown";
8
+
3
9
  export interface AuditWriteStatus {
4
10
  state: "healthy" | "degraded";
5
11
  attempted: number;
@@ -10,6 +16,8 @@ export interface AuditWriteStatus {
10
16
  dropped: number;
11
17
  lastFailureAt?: string;
12
18
  lastSuccessAt?: string;
19
+ recoveryFailure?: AuditRecoveryFailure;
20
+ lastRecoveryFailureAt?: string;
13
21
  alert?: string;
14
22
  }
15
23
 
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm, stat } from "node:fs/promises";
2
+ import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import type { AuditEvent } from "@southwind-ai/shared";
@@ -39,4 +39,51 @@ describe("FileAuditRecoveryQueue", () => {
39
39
  await Array.fromAsync(new Bun.Glob("*.tmp").scan(directory)),
40
40
  ).toEqual([]);
41
41
  });
42
+
43
+ test("合法空队列保持可读", async () => {
44
+ const directory = await mkdtemp(join(tmpdir(), "windy-audit-empty-"));
45
+ directories.push(directory);
46
+ const filePath = join(directory, "recovery.json");
47
+ const queue = new FileAuditRecoveryQueue(filePath);
48
+
49
+ await queue.replace([]);
50
+
51
+ await expect(queue.read()).resolves.toEqual([]);
52
+ });
53
+
54
+ test.each([
55
+ ["缺少 formatVersion", { events: [] }],
56
+ ["formatVersion 不受支持", { formatVersion: 2, events: [] }],
57
+ ["缺少 events", { formatVersion: 1 }],
58
+ ["events 不是数组", { formatVersion: 1, events: { secret: "不应泄露" } }],
59
+ [
60
+ "event 结构损坏",
61
+ {
62
+ formatVersion: 1,
63
+ events: [
64
+ {
65
+ id: "audit_invalid",
66
+ action: "auth.login",
67
+ outcome: "success",
68
+ actor: { id: "user_1", type: "invalid", name: "用户一" },
69
+ occurredAt: "2026-07-10T00:00:00.000Z",
70
+ secret: "不应泄露",
71
+ },
72
+ ],
73
+ },
74
+ ],
75
+ ])("%s 时以稳定无敏感内容错误拒绝", async (_, envelope) => {
76
+ const directory = await mkdtemp(join(tmpdir(), "windy-audit-invalid-"));
77
+ directories.push(directory);
78
+ const filePath = join(directory, "recovery.json");
79
+ await writeFile(filePath, JSON.stringify(envelope), "utf8");
80
+
81
+ const error = await new FileAuditRecoveryQueue(filePath)
82
+ .read()
83
+ .catch((failure: unknown) => failure);
84
+
85
+ expect(error).toBeInstanceOf(SyntaxError);
86
+ expect((error as Error).message).toBe("审计恢复队列文件格式无效");
87
+ expect(String(error)).not.toContain("不应泄露");
88
+ });
42
89
  });
@@ -2,6 +2,9 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import type { AuditEvent } from "@southwind-ai/shared";
4
4
 
5
+ const recoveryQueueFormatVersion = 1;
6
+ const invalidRecoveryQueueMessage = "审计恢复队列文件格式无效";
7
+
5
8
  export interface AuditRecoveryQueue {
6
9
  read(): Promise<AuditEvent[]>;
7
10
  replace(events: AuditEvent[]): Promise<void>;
@@ -17,6 +20,9 @@ export class FileAuditRecoveryQueue implements AuditRecoveryQueue {
17
20
  return readEvents(value);
18
21
  } catch (error) {
19
22
  if (isMissingFile(error)) return [];
23
+ if (error instanceof SyntaxError) {
24
+ throw new SyntaxError(invalidRecoveryQueueMessage);
25
+ }
20
26
  throw error;
21
27
  }
22
28
  }
@@ -26,7 +32,7 @@ export class FileAuditRecoveryQueue implements AuditRecoveryQueue {
26
32
  const temporaryPath = `${this.filePath}.${process.pid}.tmp`;
27
33
  await writeFile(
28
34
  temporaryPath,
29
- JSON.stringify({ formatVersion: 1, events }),
35
+ JSON.stringify({ formatVersion: recoveryQueueFormatVersion, events }),
30
36
  { encoding: "utf8", mode: 0o600 },
31
37
  );
32
38
  await rename(temporaryPath, this.filePath);
@@ -49,9 +55,72 @@ export class MemoryAuditRecoveryQueue implements AuditRecoveryQueue {
49
55
  }
50
56
 
51
57
  function readEvents(value: unknown): AuditEvent[] {
52
- if (!value || typeof value !== "object" || Array.isArray(value)) return [];
53
- const events = (value as Record<string, unknown>).events;
54
- return Array.isArray(events) ? (events as AuditEvent[]) : [];
58
+ if (
59
+ !isRecord(value) ||
60
+ value.formatVersion !== recoveryQueueFormatVersion ||
61
+ !Array.isArray(value.events) ||
62
+ !value.events.every(isAuditEvent)
63
+ ) {
64
+ throw new SyntaxError(invalidRecoveryQueueMessage);
65
+ }
66
+ return value.events;
67
+ }
68
+
69
+ function isAuditEvent(value: unknown): value is AuditEvent {
70
+ if (!isRecord(value) || !isRecord(value.actor)) return false;
71
+ const actor = value.actor;
72
+ return (
73
+ typeof value.id === "string" &&
74
+ typeof value.action === "string" &&
75
+ (value.outcome === "attempted" ||
76
+ value.outcome === "success" ||
77
+ value.outcome === "failure" ||
78
+ value.outcome === "denied") &&
79
+ typeof actor.id === "string" &&
80
+ (actor.type === "user" ||
81
+ actor.type === "service" ||
82
+ actor.type === "system") &&
83
+ typeof actor.name === "string" &&
84
+ isOptionalStringArray(actor.roleCodes) &&
85
+ isOptionalString(actor.ip) &&
86
+ isOptionalString(actor.userAgent) &&
87
+ isOptionalTarget(value.target) &&
88
+ isOptionalString(value.module) &&
89
+ isOptionalString(value.requestId) &&
90
+ isOptionalString(value.message) &&
91
+ isOptionalRecord(value.metadata) &&
92
+ typeof value.occurredAt === "string"
93
+ );
94
+ }
95
+
96
+ function isOptionalTarget(value: unknown): boolean {
97
+ return (
98
+ value === undefined ||
99
+ (isRecord(value) &&
100
+ typeof value.type === "string" &&
101
+ isOptionalString(value.id) &&
102
+ isOptionalString(value.label))
103
+ );
104
+ }
105
+
106
+ function isOptionalString(value: unknown): boolean {
107
+ return value === undefined || typeof value === "string";
108
+ }
109
+
110
+ function isOptionalStringArray(value: unknown): boolean {
111
+ return (
112
+ value === undefined ||
113
+ (Array.isArray(value) &&
114
+ value.every((item): item is string => typeof item === "string"))
115
+ );
116
+ }
117
+
118
+ function isOptionalRecord(value: unknown): boolean {
119
+ return value === undefined || isRecord(value);
120
+ }
121
+
122
+ function isRecord(value: unknown): value is Record<string, unknown> {
123
+ return typeof value === "object" && value !== null && !Array.isArray(value);
55
124
  }
56
125
 
57
126
  function isMissingFile(error: unknown): boolean {
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import type { AuditEvent } from "@southwind-ai/shared";
3
- import { MemoryAuditRecoveryQueue } from "./recovery-queue.js";
6
+ import {
7
+ FileAuditRecoveryQueue,
8
+ MemoryAuditRecoveryQueue,
9
+ } from "./recovery-queue.js";
4
10
  import {
5
11
  AuditWriteUnavailableError,
6
12
  ReliableAuditWriter,
@@ -127,6 +133,117 @@ describe("ReliableAuditWriter", () => {
127
133
  });
128
134
  });
129
135
 
136
+ test("恢复队列权限错误时降级启动并记录稳定诊断", async () => {
137
+ const queue = new MemoryAuditRecoveryQueue();
138
+ queue.failure = Object.assign(new Error("sensitive queue path"), {
139
+ code: "EACCES",
140
+ });
141
+ const alerts: string[] = [];
142
+ const writer = new ReliableAuditWriter(
143
+ { write: async () => undefined },
144
+ queue,
145
+ {
146
+ now: () => new Date("2026-07-28T10:00:00.000Z"),
147
+ onAlert: (status) => alerts.push(status.alert || ""),
148
+ },
149
+ );
150
+
151
+ await expect(writer.recover()).resolves.toBeUndefined();
152
+
153
+ expect(writer.status()).toMatchObject({
154
+ state: "degraded",
155
+ recoveryFailure: "permission-denied",
156
+ lastRecoveryFailureAt: "2026-07-28T10:00:00.000Z",
157
+ alert: "关键审计恢复队列不可用:文件权限不足",
158
+ });
159
+ expect(alerts).toEqual(["关键审计恢复队列不可用:文件权限不足"]);
160
+ expect(JSON.stringify(writer.status())).not.toContain(
161
+ "sensitive queue path",
162
+ );
163
+ });
164
+
165
+ test("恢复队列 JSON 损坏时降级启动且不泄露文件内容", async () => {
166
+ const directory = await mkdtemp(join(tmpdir(), "windy-audit-corrupt-"));
167
+ const filePath = join(directory, "recovery.json");
168
+ await writeFile(filePath, '{"secret":"must-not-leak"', "utf8");
169
+ const writer = new ReliableAuditWriter(
170
+ { write: async () => undefined },
171
+ new FileAuditRecoveryQueue(filePath),
172
+ { now: () => new Date("2026-07-28T10:01:00.000Z") },
173
+ );
174
+
175
+ try {
176
+ await expect(writer.recover()).resolves.toBeUndefined();
177
+
178
+ expect(writer.status()).toMatchObject({
179
+ state: "degraded",
180
+ recoveryFailure: "invalid-format",
181
+ lastRecoveryFailureAt: "2026-07-28T10:01:00.000Z",
182
+ alert: "关键审计恢复队列不可用:队列文件格式无效",
183
+ });
184
+ expect(JSON.stringify(writer.status())).not.toContain("must-not-leak");
185
+ } finally {
186
+ await rm(directory, { recursive: true, force: true });
187
+ }
188
+ });
189
+
190
+ test("普通审计落库成功不能清除恢复队列故障", async () => {
191
+ const queue = new MemoryAuditRecoveryQueue();
192
+ queue.events = [{ ...event(), id: "audit_queued" }];
193
+ queue.failure = Object.assign(new Error("queue unavailable"), {
194
+ code: "EIO",
195
+ });
196
+ const writer = new ReliableAuditWriter(
197
+ { write: async () => undefined },
198
+ queue,
199
+ { now: () => new Date("2026-07-28T10:02:00.000Z") },
200
+ );
201
+ await writer.recover();
202
+
203
+ await writer.write(event(), "ordinary");
204
+
205
+ expect(writer.status()).toMatchObject({
206
+ state: "degraded",
207
+ persisted: 1,
208
+ recoveryFailure: "io-error",
209
+ lastRecoveryFailureAt: "2026-07-28T10:02:00.000Z",
210
+ alert: "关键审计恢复队列不可用:文件读写失败",
211
+ });
212
+
213
+ queue.failure = undefined;
214
+ await writer.recover();
215
+
216
+ expect(writer.status()).toMatchObject({
217
+ state: "healthy",
218
+ persisted: 2,
219
+ queued: 0,
220
+ });
221
+ expect(queue.events).toEqual([]);
222
+ expect(writer.status().recoveryFailure).toBeUndefined();
223
+ expect(writer.status().lastRecoveryFailureAt).toBeUndefined();
224
+ expect(writer.status().alert).toBeUndefined();
225
+ });
226
+
227
+ test("恢复队列重新可写后以关键事件待重放状态替代队列故障", async () => {
228
+ const queue = new MemoryAuditRecoveryQueue();
229
+ queue.failure = Object.assign(new Error("queue unavailable"), {
230
+ code: "EACCES",
231
+ });
232
+ const writer = failingWriter(queue);
233
+ await writer.recover();
234
+ queue.failure = undefined;
235
+
236
+ await writer.write(event(), "critical");
237
+
238
+ expect(writer.status()).toMatchObject({
239
+ state: "degraded",
240
+ queued: 1,
241
+ alert: "关键审计已进入可恢复队列",
242
+ });
243
+ expect(writer.status().recoveryFailure).toBeUndefined();
244
+ expect(writer.status().lastRecoveryFailureAt).toBeUndefined();
245
+ });
246
+
130
247
  test("当前事件已落库但恢复文件故障时只告警且不误报丢弃", async () => {
131
248
  const queue = new MemoryAuditRecoveryQueue();
132
249
  let databaseAvailable = false;
@@ -152,7 +269,8 @@ describe("ReliableAuditWriter", () => {
152
269
  persisted: 1,
153
270
  queued: 1,
154
271
  dropped: 0,
155
- alert: "关键审计恢复队列不可用",
272
+ recoveryFailure: "unknown",
273
+ alert: "关键审计恢复队列不可用:未知错误",
156
274
  });
157
275
  });
158
276