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.
- package/README.md +14 -36
- package/dist/cli.js +1831 -3178
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +7 -1
- package/template/README.md +8 -11
- package/template/apps/agent-server/Dockerfile +2 -1
- package/template/apps/agent-server/README.md +13 -5
- package/template/apps/agent-server/adk_web_agents/canonical_debug/__init__.py +1 -0
- package/template/apps/agent-server/adk_web_agents/canonical_debug/agent.py +125 -0
- package/template/apps/agent-server/project/adk-web-debug.json +4 -0
- package/template/apps/agent-server/src/southwind_agent_server/adk_debug.py +315 -0
- package/template/apps/agent-server/tests/test_adk_web_canonical_debug.py +235 -0
- package/template/apps/server/Dockerfile +1 -1
- package/template/apps/server/src/agent/run-debug-authorization.ts +27 -0
- package/template/apps/server/src/agent/run-facade-adk-debug.test.ts +56 -0
- package/template/apps/server/src/agent/run-facade.test-support.ts +235 -0
- package/template/apps/server/src/agent/run-facade.test.ts +8 -244
- package/template/apps/server/src/agent/run-facade.ts +23 -48
- package/template/apps/server/src/agent/run-operation-registry.ts +51 -0
- package/template/apps/server/src/agent/runtime-bootstrap.ts +5 -2
- package/template/apps/server/src/agent/tool-audit.ts +3 -0
- package/template/apps/server/src/agent/tool-registry.test.ts +29 -0
- package/template/apps/server/src/application-services.ts +83 -15
- package/template/apps/server/src/audit/contracts.ts +8 -0
- package/template/apps/server/src/audit/recovery-queue.test.ts +48 -1
- package/template/apps/server/src/audit/recovery-queue.ts +73 -4
- package/template/apps/server/src/audit/reliable-writer.test.ts +120 -2
- package/template/apps/server/src/audit/reliable-writer.ts +98 -34
- package/template/apps/server/src/guards.ts +16 -0
- package/template/apps/server/src/index.ts +23 -20
- package/template/apps/server/src/jobs/legacy-adapter.integration.test.ts +0 -1
- package/template/apps/server/src/jobs/legacy-adapter.test.ts +6 -0
- package/template/apps/server/src/jobs/legacy-service-runtime.ts +18 -0
- package/template/apps/server/src/module-host/durable-context-unavailable.test.ts +84 -0
- package/template/apps/server/src/module-host/durable-context.test.ts +14 -0
- package/template/apps/server/src/module-host/durable-context.ts +17 -15
- package/template/apps/server/src/module-host/request-context.test.ts +30 -1
- package/template/apps/server/src/module-host/request-context.ts +15 -2
- package/template/apps/server/src/module-host/storage-port.ts +1 -1
- package/template/apps/server/src/platform-bootstrap.ts +16 -0
- package/template/apps/server/src/runtime-adk-debug.test.ts +246 -0
- package/template/apps/server/src/runtime-adk-debug.ts +158 -0
- package/template/apps/server/src/runtime-request-actor.test.ts +55 -0
- package/template/apps/server/src/runtime-request-actor.ts +22 -0
- package/template/apps/server/src/runtime.test.ts +3 -0
- package/template/apps/server/src/runtime.ts +36 -3
- package/template/apps/server/src/scheduler/definitions.test.ts +16 -0
- package/template/apps/web/Dockerfile +1 -1
- package/template/docker-compose.agent-debug.yml +56 -0
- package/template/docker-compose.yml +1 -39
- package/template/docs/architecture/ai-runtime.md +14 -5
- package/template/docs/architecture/module-manifest-composition.md +14 -0
- package/template/docs/development/custom-login-page.md +2 -16
- package/template/docs/platform/agent-runtime.md +57 -16
- package/template/docs/platform/agent-tool-sandbox.md +4 -3
- package/template/docs/platform/audit-reliability.md +33 -5
- package/template/docs/platform/platform-authentication.md +9 -1
- package/template/package.json +4 -3
- package/template/packages/database/drizzle/0043_activate_agent_dependencies.sql +196 -0
- package/template/packages/database/drizzle/meta/0043_snapshot.json +8335 -0
- package/template/packages/database/drizzle/meta/_journal.json +7 -0
- package/template/packages/database/src/schema-notification-delivery.test.ts +15 -6
- package/template/packages/database/src/schema-workflow-notification.test.ts +44 -29
- 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
|
-
[
|
|
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 =
|
|
203
|
-
(
|
|
204
|
-
|
|
205
|
-
.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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:
|
|
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 (
|
|
53
|
-
|
|
54
|
-
|
|
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 {
|
|
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
|
-
|
|
272
|
+
recoveryFailure: "unknown",
|
|
273
|
+
alert: "关键审计恢复队列不可用:未知错误",
|
|
156
274
|
});
|
|
157
275
|
});
|
|
158
276
|
|