create-windy 0.8.0 → 0.8.1
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/dist/cli.js +46 -41
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/agent-server/Dockerfile +1 -1
- package/template/apps/agent-server/README.md +1 -1
- package/template/apps/agent-server/src/southwind_agent_server/asgi.py +8 -0
- package/template/apps/agent-server/src/southwind_agent_server/runtime_logging.py +57 -0
- package/template/apps/agent-server/src/southwind_agent_server/structured_logging.py +134 -0
- package/template/apps/agent-server/src/southwind_agent_server/telemetry.py +4 -0
- package/template/apps/agent-server/tests/test_run_operational_logging.py +114 -0
- package/template/apps/agent-server/tests/test_structured_logging.py +105 -0
- package/template/apps/server/src/agent/isolated-execution-guard.test.ts +194 -0
- package/template/apps/server/src/agent/isolated-execution-guard.ts +202 -0
- package/template/apps/server/src/agent/run-definition-support.ts +11 -1
- package/template/apps/server/src/agent/run-facade.ts +7 -11
- package/template/apps/server/src/agent/run-operation-policy.ts +11 -0
- package/template/apps/server/src/agent/workflow-durable-runtime-input.ts +8 -5
- package/template/apps/server/src/jobs/legacy-service-runtime.ts +4 -0
- package/template/apps/server/src/jobs/runtime-logging.test.ts +212 -0
- package/template/apps/server/src/jobs/runtime-logging.ts +58 -0
- package/template/apps/server/src/jobs/worker.ts +54 -12
- package/template/apps/server/src/observability/bootstrap.ts +1 -4
- package/template/apps/server/src/observability/http-observability.ts +2 -3
- package/template/apps/server/src/observability/structured-logger.test.ts +31 -0
- package/template/apps/server/src/observability/structured-logger.ts +27 -0
- package/template/apps/server/src/platform-bootstrap.ts +9 -3
- package/template/apps/server/src/startup-finalization.ts +3 -3
- package/template/apps/server/src/system/built-in-roles.test.ts +23 -0
- package/template/apps/server/src/system/built-in-roles.ts +10 -4
- package/template/apps/web/src/pages/system/composables/useResourceEditor.ts +16 -5
- package/template/apps/web/src/pages/system/composables/useResourceEditor.webtest.ts +4 -0
- package/template/docs/architecture/log-governance-implementation.md +79 -0
- package/template/docs/architecture/log-governance-plan.md +39 -0
- package/template/docs/operations/observability.md +36 -4
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/ai-isolated-execution-policy.test.ts +70 -0
- package/template/packages/modules/src/ai-isolated-execution-policy.ts +76 -0
- package/template/packages/modules/src/ai-operation-composition.ts +2 -0
- package/template/packages/modules/src/manifest.ts +23 -0
- package/template/packages/modules/src/system-features.ts +11 -10
- package/template/packages/server-sdk/package.json +1 -1
- package/template/packages/shared/index.ts +1 -0
- package/template/packages/shared/package.json +3 -2
- package/template/packages/shared/src/structured-log.test.ts +147 -0
- package/template/packages/shared/src/structured-log.ts +215 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AgentRunFacadeError } from "./run-errors.js";
|
|
1
2
|
import { resolveProviderEgress } from "@southwind-ai/modules";
|
|
2
3
|
import type { RegisteredOperation } from "./run-operation-registry.js";
|
|
3
4
|
|
|
@@ -18,3 +19,13 @@ export function publicAgentOperationPolicy(
|
|
|
18
19
|
release: structuredClone(operation.release),
|
|
19
20
|
};
|
|
20
21
|
}
|
|
22
|
+
|
|
23
|
+
export function assertAgentRuntimeAssembled(operation: RegisteredOperation) {
|
|
24
|
+
if (operation.definition.isolatedExecution) {
|
|
25
|
+
throw new AgentRunFacadeError(
|
|
26
|
+
"RUNTIME_UNAVAILABLE",
|
|
27
|
+
"隔离执行 Runtime 尚未装配",
|
|
28
|
+
503,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StructuredLogger } from "@southwind-ai/shared";
|
|
1
2
|
import type { DurableHandlerRegistry } from "@southwind-ai/jobs";
|
|
2
3
|
import { DurableArtifactService } from "../jobs/artifact-service.js";
|
|
3
4
|
import type { DurableJobService } from "../jobs/service.js";
|
|
@@ -14,18 +15,20 @@ export function workflowDurableInput([durable, storage, logger]: readonly [
|
|
|
14
15
|
{
|
|
15
16
|
readonly blobStore: ConstructorParameters<typeof DurableArtifactService>[1];
|
|
16
17
|
},
|
|
17
|
-
|
|
18
|
-
error(fields: Record<string, unknown>, message: string): void;
|
|
19
|
-
},
|
|
18
|
+
Pick<StructuredLogger, "error">,
|
|
20
19
|
]) {
|
|
21
20
|
return {
|
|
22
21
|
jobs: durable.service,
|
|
23
22
|
jobWorker: durable.worker,
|
|
24
23
|
jobRegistry: durable.registry,
|
|
25
24
|
jobArtifacts: new DurableArtifactService(durable.facts, storage.blobStore),
|
|
26
|
-
onWorkflowContinuationError: (
|
|
25
|
+
onWorkflowContinuationError: (_error: unknown) =>
|
|
27
26
|
logger.error(
|
|
28
|
-
{
|
|
27
|
+
{
|
|
28
|
+
event: "agent.workflow.continuation.failed",
|
|
29
|
+
outcome: "failure",
|
|
30
|
+
errorCode: "AGENT_WORKFLOW_CONTINUATION_FAILED",
|
|
31
|
+
},
|
|
29
32
|
"Agent Workflow continuation 派发失败",
|
|
30
33
|
),
|
|
31
34
|
};
|
|
@@ -10,6 +10,8 @@ import type { BulkDataRepository } from "../bulk-data/repository.js";
|
|
|
10
10
|
import type { BulkResourceHandler } from "../bulk-data/resource-handler.js";
|
|
11
11
|
// @windy-module system.bulk-data end
|
|
12
12
|
import type { createServerRuntime } from "../runtime.js";
|
|
13
|
+
import type { StructuredLogger } from "@southwind-ai/shared";
|
|
14
|
+
import { createServerLogger } from "../observability/structured-logger.js";
|
|
13
15
|
import type { RequestActor } from "../guards.js";
|
|
14
16
|
// @windy-module system.bulk-data begin
|
|
15
17
|
import { evaluateAccessGuards } from "../access-guards.js";
|
|
@@ -48,6 +50,7 @@ interface LegacyServiceRuntimeInput {
|
|
|
48
50
|
// @windy-module system.workflow end
|
|
49
51
|
installModuleDurableHandlers?: (registry: DurableHandlerRegistry) => void;
|
|
50
52
|
durableContextualize?: DurableWorkerOptions["contextualize"];
|
|
53
|
+
logger?: StructuredLogger;
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
export function createLegacyServiceRuntime(input: LegacyServiceRuntimeInput) {
|
|
@@ -61,6 +64,7 @@ export function createLegacyServiceRuntime(input: LegacyServiceRuntimeInput) {
|
|
|
61
64
|
input.installModuleDurableHandlers?.(registry);
|
|
62
65
|
const worker = new DurableJobWorker(execution, registry, {
|
|
63
66
|
contextualize: input.durableContextualize,
|
|
67
|
+
logger: input.logger ?? createServerLogger(),
|
|
64
68
|
});
|
|
65
69
|
// @windy-module system.workflow begin
|
|
66
70
|
const workflowTimeout = new WorkflowTimeoutRuntime(
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { DurableHandlerRegistry } from "@southwind-ai/jobs";
|
|
3
|
+
import {
|
|
4
|
+
createStructuredLogger,
|
|
5
|
+
type StructuredLogger,
|
|
6
|
+
} from "@southwind-ai/shared";
|
|
7
|
+
import { InMemoryDurableExecutionRepository } from "./in-memory-execution.js";
|
|
8
|
+
import { DurableJobService } from "./service.js";
|
|
9
|
+
import { DurableJobWorker } from "./worker.js";
|
|
10
|
+
|
|
11
|
+
const base = new Date("2026-09-30T00:00:00.000Z");
|
|
12
|
+
|
|
13
|
+
function capture() {
|
|
14
|
+
const records: Record<string, unknown>[] = [];
|
|
15
|
+
const write = (record: Record<string, unknown>) => records.push(record);
|
|
16
|
+
const logger = createStructuredLogger(
|
|
17
|
+
{ info: write, warn: write, error: write },
|
|
18
|
+
"windy-server",
|
|
19
|
+
);
|
|
20
|
+
return { logger, records };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function fixture(
|
|
24
|
+
handler?: () => void | Promise<void>,
|
|
25
|
+
logger?: StructuredLogger,
|
|
26
|
+
) {
|
|
27
|
+
const repository = new InMemoryDurableExecutionRepository();
|
|
28
|
+
const service = new DurableJobService(repository, () => base);
|
|
29
|
+
const registry = new DurableHandlerRegistry();
|
|
30
|
+
if (handler)
|
|
31
|
+
registry.register({
|
|
32
|
+
key: "test.handler",
|
|
33
|
+
payload: { parse: (value) => value },
|
|
34
|
+
execute: handler,
|
|
35
|
+
});
|
|
36
|
+
const worker = new DurableJobWorker(repository, registry, {
|
|
37
|
+
logger,
|
|
38
|
+
now: () => base,
|
|
39
|
+
leaseDurationMs: 60_000,
|
|
40
|
+
});
|
|
41
|
+
return { service, worker };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("Durable Job runtime logging", () => {
|
|
45
|
+
test("idle 无日志;每个实际 attempt 仅一条开始和一条完成", async () => {
|
|
46
|
+
const { logger, records } = capture();
|
|
47
|
+
const { service, worker } = await fixture(() => undefined, logger);
|
|
48
|
+
expect(await worker.runNext("default", "worker-a")).toEqual({
|
|
49
|
+
status: "idle",
|
|
50
|
+
});
|
|
51
|
+
expect(records).toEqual([]);
|
|
52
|
+
const submitted = await service.submit({
|
|
53
|
+
handlerKey: "test.handler",
|
|
54
|
+
idempotencyKey: "job-log-success",
|
|
55
|
+
payload: { secret: "secret-payload" },
|
|
56
|
+
requestedBy: "user-secret",
|
|
57
|
+
stageKeys: ["prepare", "apply"],
|
|
58
|
+
});
|
|
59
|
+
expect(await worker.runNext("default", "worker-a")).toEqual({
|
|
60
|
+
status: "succeeded",
|
|
61
|
+
jobId: submitted.job.id,
|
|
62
|
+
});
|
|
63
|
+
expect(await worker.runNext("default", "worker-a")).toEqual({
|
|
64
|
+
status: "succeeded",
|
|
65
|
+
jobId: submitted.job.id,
|
|
66
|
+
});
|
|
67
|
+
expect(records.map((record) => record.event)).toEqual([
|
|
68
|
+
"job.attempt.started",
|
|
69
|
+
"job.attempt.completed",
|
|
70
|
+
"job.attempt.started",
|
|
71
|
+
"job.attempt.completed",
|
|
72
|
+
]);
|
|
73
|
+
expect(records.map((record) => record.stageKey)).toEqual([
|
|
74
|
+
"prepare",
|
|
75
|
+
"prepare",
|
|
76
|
+
"apply",
|
|
77
|
+
"apply",
|
|
78
|
+
]);
|
|
79
|
+
for (const record of records) {
|
|
80
|
+
expect(record).toMatchObject({
|
|
81
|
+
jobId: submitted.job.id,
|
|
82
|
+
attemptNumber: 1,
|
|
83
|
+
handlerKey: "test.handler",
|
|
84
|
+
queue: "default",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const serialized = JSON.stringify(records);
|
|
88
|
+
expect(serialized).not.toContain("secret-payload");
|
|
89
|
+
expect(serialized).not.toContain("user-secret");
|
|
90
|
+
expect(serialized).not.toContain("leaseToken");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("未注册 handler 与执行失败都配对,且只记录稳定错误码", async () => {
|
|
94
|
+
const missing = capture();
|
|
95
|
+
const missingFixture = await fixture(undefined, missing.logger);
|
|
96
|
+
await missingFixture.service.submit({
|
|
97
|
+
handlerKey: "test.handler",
|
|
98
|
+
idempotencyKey: "job-missing",
|
|
99
|
+
payload: { secret: "secret-payload" },
|
|
100
|
+
requestedBy: "user-1",
|
|
101
|
+
});
|
|
102
|
+
expect(
|
|
103
|
+
(await missingFixture.worker.runNext("default", "worker-a")).status,
|
|
104
|
+
).toBe("failed");
|
|
105
|
+
expect(missing.records.map((record) => record.event)).toEqual([
|
|
106
|
+
"job.attempt.started",
|
|
107
|
+
"job.attempt.completed",
|
|
108
|
+
]);
|
|
109
|
+
expect(missing.records[1]?.errorCode).toBe("HANDLER_NOT_REGISTERED");
|
|
110
|
+
|
|
111
|
+
const failed = capture();
|
|
112
|
+
const failedFixture = await fixture(() => {
|
|
113
|
+
throw new Error("secret-error");
|
|
114
|
+
}, failed.logger);
|
|
115
|
+
await failedFixture.service.submit({
|
|
116
|
+
handlerKey: "test.handler",
|
|
117
|
+
idempotencyKey: "job-failed",
|
|
118
|
+
payload: { secret: "secret-payload" },
|
|
119
|
+
requestedBy: "user-1",
|
|
120
|
+
});
|
|
121
|
+
expect(
|
|
122
|
+
(await failedFixture.worker.runNext("default", "worker-a")).status,
|
|
123
|
+
).toBe("failed");
|
|
124
|
+
expect(failed.records.map((record) => record.event)).toEqual([
|
|
125
|
+
"job.attempt.started",
|
|
126
|
+
"job.attempt.completed",
|
|
127
|
+
]);
|
|
128
|
+
expect(failed.records[1]?.errorCode).toBe("JOB_ATTEMPT_FAILED");
|
|
129
|
+
expect(JSON.stringify(failed.records)).not.toContain("secret-error");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("取消与租约失效各写一次安全终态", async () => {
|
|
133
|
+
const cancelled = capture();
|
|
134
|
+
let entered: () => void = () => undefined;
|
|
135
|
+
let release: () => void = () => undefined;
|
|
136
|
+
const enteredPromise = new Promise<void>((resolve) => {
|
|
137
|
+
entered = resolve;
|
|
138
|
+
});
|
|
139
|
+
const releasePromise = new Promise<void>((resolve) => {
|
|
140
|
+
release = resolve;
|
|
141
|
+
});
|
|
142
|
+
const cancelledFixture = await fixture(async () => {
|
|
143
|
+
entered();
|
|
144
|
+
await releasePromise;
|
|
145
|
+
}, cancelled.logger);
|
|
146
|
+
const cancelledJob = await cancelledFixture.service.submit({
|
|
147
|
+
handlerKey: "test.handler",
|
|
148
|
+
idempotencyKey: "job-cancelled",
|
|
149
|
+
payload: {},
|
|
150
|
+
requestedBy: "user-1",
|
|
151
|
+
});
|
|
152
|
+
const run = cancelledFixture.worker.runNext("default", "worker-a");
|
|
153
|
+
await enteredPromise;
|
|
154
|
+
await cancelledFixture.worker.cancel(cancelledJob.job.id);
|
|
155
|
+
release();
|
|
156
|
+
expect((await run).status).toBe("cancelled");
|
|
157
|
+
expect(cancelled.records.map((record) => record.event)).toEqual([
|
|
158
|
+
"job.attempt.started",
|
|
159
|
+
"job.attempt.completed",
|
|
160
|
+
]);
|
|
161
|
+
expect(cancelled.records[1]?.errorCode).toBe("JOB_CANCELLED");
|
|
162
|
+
|
|
163
|
+
const lost = capture();
|
|
164
|
+
const repository = new InMemoryDurableExecutionRepository();
|
|
165
|
+
const service = new DurableJobService(repository, () => base);
|
|
166
|
+
await service.submit({
|
|
167
|
+
handlerKey: "test.handler",
|
|
168
|
+
idempotencyKey: "job-lease-lost",
|
|
169
|
+
payload: {},
|
|
170
|
+
requestedBy: "user-1",
|
|
171
|
+
});
|
|
172
|
+
let now = base;
|
|
173
|
+
const registry = new DurableHandlerRegistry().register({
|
|
174
|
+
key: "test.handler",
|
|
175
|
+
payload: { parse: (value) => value },
|
|
176
|
+
execute: async (_payload, context) => {
|
|
177
|
+
now = new Date(base.getTime() + 100);
|
|
178
|
+
await context.heartbeat();
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
const worker = new DurableJobWorker(repository, registry, {
|
|
182
|
+
logger: lost.logger,
|
|
183
|
+
now: () => now,
|
|
184
|
+
leaseDurationMs: 10,
|
|
185
|
+
});
|
|
186
|
+
expect((await worker.runNext("default", "worker-a")).status).toBe(
|
|
187
|
+
"lease-lost",
|
|
188
|
+
);
|
|
189
|
+
expect(lost.records.map((record) => record.event)).toEqual([
|
|
190
|
+
"job.attempt.started",
|
|
191
|
+
"job.attempt.completed",
|
|
192
|
+
]);
|
|
193
|
+
expect(lost.records[1]?.errorCode).toBe("JOB_LEASE_LOST");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("logger 抛错不改变 Job 结果", async () => {
|
|
197
|
+
const fail = () => {
|
|
198
|
+
throw new Error("sink down");
|
|
199
|
+
};
|
|
200
|
+
const logger: StructuredLogger = { info: fail, warn: fail, error: fail };
|
|
201
|
+
const { service, worker } = await fixture(() => undefined, logger);
|
|
202
|
+
await service.submit({
|
|
203
|
+
handlerKey: "test.handler",
|
|
204
|
+
idempotencyKey: "sink-failure",
|
|
205
|
+
payload: {},
|
|
206
|
+
requestedBy: "user-1",
|
|
207
|
+
});
|
|
208
|
+
expect((await worker.runNext("default", "worker-a")).status).toBe(
|
|
209
|
+
"succeeded",
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ClaimedDurableJob } from "@southwind-ai/jobs";
|
|
2
|
+
import type { StructuredLogger } from "@southwind-ai/shared";
|
|
3
|
+
|
|
4
|
+
export type JobAttemptResult =
|
|
5
|
+
| "succeeded"
|
|
6
|
+
| "failed"
|
|
7
|
+
| "cancelled"
|
|
8
|
+
| "lease-lost";
|
|
9
|
+
|
|
10
|
+
/** Only canonical claim coordinates enter operational logs. */
|
|
11
|
+
export function logJobAttemptStarted(
|
|
12
|
+
logger: StructuredLogger | undefined,
|
|
13
|
+
claim: ClaimedDurableJob,
|
|
14
|
+
): void {
|
|
15
|
+
safeLog(() =>
|
|
16
|
+
logger?.info({
|
|
17
|
+
event: "job.attempt.started",
|
|
18
|
+
jobId: claim.jobId,
|
|
19
|
+
stageKey: claim.stage.key,
|
|
20
|
+
attemptNumber: claim.stage.currentAttempt,
|
|
21
|
+
handlerKey: claim.job.handlerKey,
|
|
22
|
+
queue: claim.job.queue,
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function logJobAttemptCompleted(
|
|
28
|
+
logger: StructuredLogger | undefined,
|
|
29
|
+
claim: ClaimedDurableJob,
|
|
30
|
+
status: JobAttemptResult,
|
|
31
|
+
durationMs: number,
|
|
32
|
+
errorCode?: string,
|
|
33
|
+
): void {
|
|
34
|
+
const fields = {
|
|
35
|
+
event: "job.attempt.completed",
|
|
36
|
+
jobId: claim.jobId,
|
|
37
|
+
stageKey: claim.stage.key,
|
|
38
|
+
attemptNumber: claim.stage.currentAttempt,
|
|
39
|
+
handlerKey: claim.job.handlerKey,
|
|
40
|
+
queue: claim.job.queue,
|
|
41
|
+
durationMs: Number(durationMs.toFixed(3)),
|
|
42
|
+
outcome:
|
|
43
|
+
status === "succeeded" ? ("success" as const) : ("failure" as const),
|
|
44
|
+
...(errorCode ? { errorCode } : {}),
|
|
45
|
+
};
|
|
46
|
+
safeLog(() => {
|
|
47
|
+
if (status === "failed" || status === "lease-lost") logger?.error(fields);
|
|
48
|
+
else logger?.info(fields);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function safeLog(write: () => void): void {
|
|
53
|
+
try {
|
|
54
|
+
write();
|
|
55
|
+
} catch {
|
|
56
|
+
// Runtime logging is best effort; Job fact writes retain their own contract.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -5,6 +5,11 @@ import type {
|
|
|
5
5
|
ManualJobRecovery,
|
|
6
6
|
} from "./execution-repository.js";
|
|
7
7
|
import { createDurableArtifactPublisher } from "./artifact-publisher.js";
|
|
8
|
+
import type { StructuredLogger } from "@southwind-ai/shared";
|
|
9
|
+
import {
|
|
10
|
+
logJobAttemptCompleted,
|
|
11
|
+
logJobAttemptStarted,
|
|
12
|
+
} from "./runtime-logging.js";
|
|
8
13
|
import {
|
|
9
14
|
DurableHandlerError,
|
|
10
15
|
type DurableHandlerContext,
|
|
@@ -25,6 +30,7 @@ export interface DurableWorkerOptions {
|
|
|
25
30
|
leaseDurationMs?: number;
|
|
26
31
|
retryDelayMs?: number;
|
|
27
32
|
now?: () => Date;
|
|
33
|
+
logger?: StructuredLogger;
|
|
28
34
|
contextualize?: (input: {
|
|
29
35
|
claim: ClaimedDurableJob;
|
|
30
36
|
moduleName?: string;
|
|
@@ -37,6 +43,7 @@ export class DurableJobWorker {
|
|
|
37
43
|
private readonly retryDelayMs: number;
|
|
38
44
|
private readonly now: () => Date;
|
|
39
45
|
private readonly contextualize?: DurableWorkerOptions["contextualize"];
|
|
46
|
+
private readonly logger?: StructuredLogger;
|
|
40
47
|
private readonly activeRuns = new Map<string, AbortController>();
|
|
41
48
|
|
|
42
49
|
constructor(
|
|
@@ -48,6 +55,7 @@ export class DurableJobWorker {
|
|
|
48
55
|
this.retryDelayMs = options.retryDelayMs || 1_000;
|
|
49
56
|
this.now = options.now || (() => new Date());
|
|
50
57
|
this.contextualize = options.contextualize;
|
|
58
|
+
this.logger = options.logger;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
async runNext(queue: string, workerId: string): Promise<DurableRunResult> {
|
|
@@ -59,20 +67,54 @@ export class DurableJobWorker {
|
|
|
59
67
|
leaseDurationMs: this.leaseDurationMs,
|
|
60
68
|
});
|
|
61
69
|
if (!claim) return { status: "idle" };
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
const startedAt = performance.now();
|
|
71
|
+
logJobAttemptStarted(this.logger, claim);
|
|
72
|
+
try {
|
|
73
|
+
const handler = this.handlers.get(claim.job.handlerKey);
|
|
74
|
+
if (!handler) {
|
|
75
|
+
await this.repository.fail(
|
|
76
|
+
claim,
|
|
77
|
+
{
|
|
78
|
+
code: "HANDLER_NOT_REGISTERED",
|
|
79
|
+
message: `任务 handler 未注册:${claim.job.handlerKey}`,
|
|
80
|
+
retryable: false,
|
|
81
|
+
},
|
|
82
|
+
this.now(),
|
|
83
|
+
);
|
|
84
|
+
logJobAttemptCompleted(
|
|
85
|
+
this.logger,
|
|
86
|
+
claim,
|
|
87
|
+
"failed",
|
|
88
|
+
performance.now() - startedAt,
|
|
89
|
+
"HANDLER_NOT_REGISTERED",
|
|
90
|
+
);
|
|
91
|
+
return { status: "failed", jobId: claim.jobId };
|
|
92
|
+
}
|
|
93
|
+
const result = await this.execute(claim, handler);
|
|
94
|
+
logJobAttemptCompleted(
|
|
95
|
+
this.logger,
|
|
65
96
|
claim,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
97
|
+
result.status,
|
|
98
|
+
performance.now() - startedAt,
|
|
99
|
+
result.status === "failed"
|
|
100
|
+
? "JOB_ATTEMPT_FAILED"
|
|
101
|
+
: result.status === "cancelled"
|
|
102
|
+
? "JOB_CANCELLED"
|
|
103
|
+
: result.status === "lease-lost"
|
|
104
|
+
? "JOB_LEASE_LOST"
|
|
105
|
+
: undefined,
|
|
106
|
+
);
|
|
107
|
+
return result;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
logJobAttemptCompleted(
|
|
110
|
+
this.logger,
|
|
111
|
+
claim,
|
|
112
|
+
"failed",
|
|
113
|
+
performance.now() - startedAt,
|
|
114
|
+
"JOB_WORKER_ERROR",
|
|
72
115
|
);
|
|
73
|
-
|
|
116
|
+
throw error;
|
|
74
117
|
}
|
|
75
|
-
return this.execute(claim, handler);
|
|
76
118
|
}
|
|
77
119
|
|
|
78
120
|
async cancel(jobId: string) {
|
|
@@ -104,7 +146,7 @@ export class DurableJobWorker {
|
|
|
104
146
|
private async execute(
|
|
105
147
|
claim: ClaimedDurableJob,
|
|
106
148
|
handler: RegisteredDurableHandler,
|
|
107
|
-
): Promise<DurableRunResult
|
|
149
|
+
): Promise<Exclude<DurableRunResult, { status: "idle" }>> {
|
|
108
150
|
const abort = new AbortController();
|
|
109
151
|
this.activeRuns.set(claim.jobId, abort);
|
|
110
152
|
const identity = identityOf(claim);
|
|
@@ -49,8 +49,5 @@ export function logPlatformStartup(
|
|
|
49
49
|
{ event: "feature.manifest.synchronized", ...input.featureSync },
|
|
50
50
|
"Feature Manifest 已同步",
|
|
51
51
|
);
|
|
52
|
-
logger.info(
|
|
53
|
-
{ event: "service.started", component: "windy-server", port: input.port },
|
|
54
|
-
"服务已启动",
|
|
55
|
-
);
|
|
52
|
+
logger.info({ event: "service.started", port: input.port }, "服务已启动");
|
|
56
53
|
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
+
import type { StructuredLogger } from "@southwind-ai/shared";
|
|
1
2
|
import type { RequestGuardContext } from "../guards.js";
|
|
2
3
|
import type { RequestObservation, RuntimeMetrics } from "./metrics.js";
|
|
3
4
|
|
|
4
|
-
export
|
|
5
|
-
info(fields: Record<string, unknown>, message: string): void;
|
|
6
|
-
}
|
|
5
|
+
export type StructuredRequestLogger = Pick<StructuredLogger, "info">;
|
|
7
6
|
|
|
8
7
|
export class HttpObservability {
|
|
9
8
|
constructor(
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createServerLogger } from "./structured-logger.js";
|
|
3
|
+
|
|
4
|
+
describe("Server Pino operational logger", () => {
|
|
5
|
+
test("实际输出单行 JSON 且没有 Pino 隐含字段或自由文本", () => {
|
|
6
|
+
const lines: string[] = [];
|
|
7
|
+
const logger = createServerLogger({ write: (line) => lines.push(line) });
|
|
8
|
+
logger.error(
|
|
9
|
+
{
|
|
10
|
+
event: "agent.workflow.continuation.failed",
|
|
11
|
+
outcome: "failure",
|
|
12
|
+
errorCode: "AGENT_WORKFLOW_CONTINUATION_FAILED",
|
|
13
|
+
requestId: "request-1",
|
|
14
|
+
error: new Error("secret-stack"),
|
|
15
|
+
} as unknown as Parameters<typeof logger.error>[0],
|
|
16
|
+
"secret-message",
|
|
17
|
+
);
|
|
18
|
+
expect(lines).toHaveLength(1);
|
|
19
|
+
const record = JSON.parse(lines[0] ?? "") as Record<string, unknown>;
|
|
20
|
+
expect(record).toMatchObject({
|
|
21
|
+
level: "error",
|
|
22
|
+
component: "windy-server",
|
|
23
|
+
event: "agent.workflow.continuation.failed",
|
|
24
|
+
requestId: "request-1",
|
|
25
|
+
});
|
|
26
|
+
expect(record).not.toHaveProperty("time");
|
|
27
|
+
expect(record).not.toHaveProperty("msg");
|
|
28
|
+
expect(lines[0]).not.toContain("secret-stack");
|
|
29
|
+
expect(lines[0]).not.toContain("secret-message");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createStructuredLogger } from "@southwind-ai/shared";
|
|
2
|
+
import pino, { type DestinationStream } from "pino";
|
|
3
|
+
|
|
4
|
+
/** Pino 只负责写出;字段选择和大小限制由共享契约完成。 */
|
|
5
|
+
export function createServerLogger(destination?: DestinationStream) {
|
|
6
|
+
const options = {
|
|
7
|
+
base: null,
|
|
8
|
+
timestamp: false,
|
|
9
|
+
formatters: { level: (level: string) => ({ level }) },
|
|
10
|
+
};
|
|
11
|
+
const sink = destination ? pino(options, destination) : pino(options);
|
|
12
|
+
const write = (
|
|
13
|
+
level: "info" | "warn" | "error",
|
|
14
|
+
record: Record<string, unknown>,
|
|
15
|
+
) => {
|
|
16
|
+
const { level: _level, ...fields } = record;
|
|
17
|
+
sink[level](fields);
|
|
18
|
+
};
|
|
19
|
+
return createStructuredLogger(
|
|
20
|
+
{
|
|
21
|
+
info: (record) => write("info", record),
|
|
22
|
+
warn: (record) => write("warn", record),
|
|
23
|
+
error: (record) => write("error", record),
|
|
24
|
+
},
|
|
25
|
+
"windy-server",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { loadPlatformConfig } from "@southwind-ai/config";
|
|
2
|
-
import
|
|
2
|
+
import { createServerLogger } from "./observability/structured-logger.js";
|
|
3
3
|
import { createServerPersistence } from "./persistence.js";
|
|
4
4
|
|
|
5
5
|
export function createPlatformBootstrap() {
|
|
6
|
-
const logger =
|
|
6
|
+
const logger = createServerLogger();
|
|
7
7
|
const platformConfig = loadPlatformConfig();
|
|
8
8
|
const persistence = createServerPersistence(
|
|
9
9
|
process.env,
|
|
10
10
|
(status) => {
|
|
11
|
-
logger.error({
|
|
11
|
+
logger.error({
|
|
12
|
+
event: "audit.persistence.degraded",
|
|
13
|
+
outcome: "failure",
|
|
14
|
+
errorCode: "AUDIT_PERSISTENCE_DEGRADED",
|
|
15
|
+
queued: status.queued,
|
|
16
|
+
dropped: status.dropped,
|
|
17
|
+
});
|
|
12
18
|
},
|
|
13
19
|
platformConfig,
|
|
14
20
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StructuredLogger } from "@southwind-ai/shared";
|
|
1
2
|
import type { ModuleManifest } from "@southwind-ai/modules";
|
|
2
3
|
import type { InitializedModuleHosts } from "./module-host.js";
|
|
3
4
|
import { validateInstalledRuntime } from "./module-runtime-validation.js";
|
|
@@ -16,9 +17,8 @@ export function finalizePlatformStartup(input: {
|
|
|
16
17
|
providers?: readonly string[];
|
|
17
18
|
schedulerWorker?: { start(): void; stop(): void };
|
|
18
19
|
disposeAgentRuntime?: () => Promise<unknown> | unknown;
|
|
19
|
-
logger: Parameters<typeof logServerStartup>[0] &
|
|
20
|
-
|
|
21
|
-
};
|
|
20
|
+
logger: Parameters<typeof logServerStartup>[0] &
|
|
21
|
+
Pick<StructuredLogger, "error">;
|
|
22
22
|
featureSync: Parameters<typeof logServerStartup>[1];
|
|
23
23
|
persistenceEnabled: boolean;
|
|
24
24
|
}): void {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createFoundationSnapshot } from "../foundation.js";
|
|
3
|
+
import { createBuiltInRoleSeeds } from "./built-in-roles.js";
|
|
4
|
+
|
|
5
|
+
test("built-in roles only grant permissions in the installed capability catalog", () => {
|
|
6
|
+
const snapshot = createFoundationSnapshot();
|
|
7
|
+
const coreKeys = new Set([
|
|
8
|
+
"system.menu.read",
|
|
9
|
+
"system.profile.read",
|
|
10
|
+
"system.user.read",
|
|
11
|
+
]);
|
|
12
|
+
snapshot.permissions = snapshot.permissions.filter(({ key }) =>
|
|
13
|
+
coreKeys.has(key),
|
|
14
|
+
);
|
|
15
|
+
const roles = createBuiltInRoleSeeds(snapshot);
|
|
16
|
+
for (const role of roles) {
|
|
17
|
+
expect(role.permissionKeys.every((key) => coreKeys.has(key))).toBe(true);
|
|
18
|
+
}
|
|
19
|
+
expect(roles.find(({ code }) => code === "user")?.permissionKeys).toEqual([
|
|
20
|
+
"system.menu.read",
|
|
21
|
+
"system.profile.read",
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
@@ -25,17 +25,23 @@ export function createBuiltInRoleSeeds(
|
|
|
25
25
|
snapshot: FoundationSnapshot,
|
|
26
26
|
): RoleSeed[] {
|
|
27
27
|
const allPermissions = snapshot.permissions.map(({ key }) => key).sort();
|
|
28
|
+
const registeredPermissions = new Set<string>(allPermissions);
|
|
29
|
+
const supportedOrdinaryPermissions = ordinaryPermissions.filter((key) =>
|
|
30
|
+
registeredPermissions.has(key),
|
|
31
|
+
);
|
|
28
32
|
const developerPermissions = snapshot.permissions
|
|
29
33
|
.filter(({ accessRisk }) => !accessRisk || accessRisk === "standard")
|
|
30
34
|
.map(({ key }) => key)
|
|
31
35
|
.sort();
|
|
32
36
|
const allMenus = collectNavigableMenuKeys(snapshot.menus);
|
|
33
37
|
const departmentManagerPermissions = [
|
|
34
|
-
...
|
|
38
|
+
...supportedOrdinaryPermissions,
|
|
35
39
|
"system.department.read",
|
|
36
40
|
"system.user.read",
|
|
37
41
|
"system.user-directory.read",
|
|
38
|
-
]
|
|
42
|
+
]
|
|
43
|
+
.filter((key) => registeredPermissions.has(key))
|
|
44
|
+
.sort();
|
|
39
45
|
return [
|
|
40
46
|
role(
|
|
41
47
|
"role_super_admin",
|
|
@@ -58,8 +64,8 @@ export function createBuiltInRoleSeeds(
|
|
|
58
64
|
"user",
|
|
59
65
|
"普通用户",
|
|
60
66
|
"self",
|
|
61
|
-
|
|
62
|
-
menuKeysForPermissions(snapshot,
|
|
67
|
+
supportedOrdinaryPermissions,
|
|
68
|
+
menuKeysForPermissions(snapshot, supportedOrdinaryPermissions),
|
|
63
69
|
),
|
|
64
70
|
role(
|
|
65
71
|
"role_developer",
|
|
@@ -33,6 +33,17 @@ export function useResourceEditor(options: {
|
|
|
33
33
|
try {
|
|
34
34
|
const normalized = { ...payload };
|
|
35
35
|
delete normalized._transitionTargetDepartmentId;
|
|
36
|
+
if (
|
|
37
|
+
options.key === "users" &&
|
|
38
|
+
!item.value &&
|
|
39
|
+
(typeof payload.temporaryPassword !== "string" ||
|
|
40
|
+
!payload.temporaryPassword)
|
|
41
|
+
) {
|
|
42
|
+
toast.error("请设置一次性初始密码", {
|
|
43
|
+
description: "密码至少 12 个字符,创建后首次登录即可修改。",
|
|
44
|
+
});
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
36
47
|
if (
|
|
37
48
|
options.key === "departments" &&
|
|
38
49
|
item.value &&
|
|
@@ -54,12 +65,12 @@ export function useResourceEditor(options: {
|
|
|
54
65
|
? await saveUserAccount(
|
|
55
66
|
normalized,
|
|
56
67
|
savedAccountId.value,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
async (accountPayload, id) => {
|
|
69
|
+
const record = await options.save(accountPayload, id);
|
|
70
|
+
if (record) savedAccountId.value = record.id;
|
|
71
|
+
return record;
|
|
61
72
|
},
|
|
62
|
-
|
|
73
|
+
toast,
|
|
63
74
|
)
|
|
64
75
|
: await options.save(normalized, item.value?.id);
|
|
65
76
|
if (saved) options.mutated();
|