create-windy 0.3.4 → 0.3.5
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 +582 -109
- 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
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { MemoryBlobStore } from "@southwind-ai/storage";
|
|
3
3
|
import { DurableArtifactService } from "./artifact-service.js";
|
|
4
|
-
import {
|
|
4
|
+
import { InMemoryDurableExecutionRepository } from "./in-memory-execution.js";
|
|
5
5
|
import { DurableJobService } from "./service.js";
|
|
6
6
|
|
|
7
7
|
describe("DurableArtifactService", () => {
|
|
8
8
|
test("只有 Owner 或 manage 权限可读取且过期后统一拒绝", async () => {
|
|
9
9
|
let now = new Date("2026-07-16T00:00:00.000Z");
|
|
10
|
-
const repository = new
|
|
10
|
+
const repository = new InMemoryDurableExecutionRepository();
|
|
11
11
|
const jobs = new DurableJobService(repository, () => now);
|
|
12
12
|
const submitted = await jobs.submit({
|
|
13
13
|
handlerKey: "test.artifact",
|
|
@@ -70,6 +70,18 @@ describe("DurableArtifactService", () => {
|
|
|
70
70
|
).rejects.toMatchObject({
|
|
71
71
|
code: "ARTIFACT_EXPIRED",
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
now = new Date("2026-07-16T12:00:00.000Z");
|
|
75
|
+
await repository.requestCancel({
|
|
76
|
+
jobId: submitted.job.id,
|
|
77
|
+
expectedStatus: "queued",
|
|
78
|
+
now,
|
|
79
|
+
});
|
|
80
|
+
await expect(
|
|
81
|
+
service.open(submitted.job.id, "result", "owner-1"),
|
|
82
|
+
).rejects.toMatchObject({
|
|
83
|
+
code: "ARTIFACT_JOB_CANCELLED",
|
|
84
|
+
});
|
|
73
85
|
});
|
|
74
86
|
});
|
|
75
87
|
|
|
@@ -5,6 +5,7 @@ import type { DurableJobRepository } from "./repository.js";
|
|
|
5
5
|
export type DurableArtifactErrorCode =
|
|
6
6
|
| "ARTIFACT_NOT_FOUND"
|
|
7
7
|
| "ARTIFACT_FORBIDDEN"
|
|
8
|
+
| "ARTIFACT_JOB_CANCELLED"
|
|
8
9
|
| "ARTIFACT_EXPIRED"
|
|
9
10
|
| "ARTIFACT_CONTENT_MISSING";
|
|
10
11
|
|
|
@@ -54,6 +55,15 @@ export class DurableArtifactService {
|
|
|
54
55
|
if (artifact.ownerId !== actorId && !allowManage) {
|
|
55
56
|
throw new DurableArtifactError("ARTIFACT_FORBIDDEN", "无权访问任务产物");
|
|
56
57
|
}
|
|
58
|
+
if (
|
|
59
|
+
snapshot?.job.status === "cancelled" ||
|
|
60
|
+
snapshot?.job.cancelRequestedAt
|
|
61
|
+
) {
|
|
62
|
+
throw new DurableArtifactError(
|
|
63
|
+
"ARTIFACT_JOB_CANCELLED",
|
|
64
|
+
"已取消任务的产物不可访问",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
57
67
|
if (artifact.expiresAt && artifact.expiresAt <= this.now().toISOString()) {
|
|
58
68
|
throw new DurableArtifactError("ARTIFACT_EXPIRED", "任务产物已过期");
|
|
59
69
|
}
|
|
@@ -11,6 +11,8 @@ import { and, eq, max, notInArray } from "drizzle-orm";
|
|
|
11
11
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
12
12
|
import type {
|
|
13
13
|
ClaimDurableJob,
|
|
14
|
+
CancelDurableJob,
|
|
15
|
+
DurableJobControlResult,
|
|
14
16
|
DurableExecutionRepository,
|
|
15
17
|
FailDurableJob,
|
|
16
18
|
LeaseResult,
|
|
@@ -220,24 +222,21 @@ export class DrizzleDurableExecutionRepository implements DurableExecutionReposi
|
|
|
220
222
|
});
|
|
221
223
|
}
|
|
222
224
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
return true;
|
|
240
|
-
});
|
|
225
|
+
requestCancel(
|
|
226
|
+
input: CancelDurableJob,
|
|
227
|
+
): Promise<DurableJobControlResult>;
|
|
228
|
+
requestCancel(jobId: string, now: Date): Promise<boolean>;
|
|
229
|
+
async requestCancel(
|
|
230
|
+
input: CancelDurableJob | string,
|
|
231
|
+
now?: Date,
|
|
232
|
+
) {
|
|
233
|
+
const legacy = typeof input === "string";
|
|
234
|
+
const result = await this.recovery.requestCancel(
|
|
235
|
+
legacy ? { jobId: input, now: now! } : input,
|
|
236
|
+
);
|
|
237
|
+
return legacy
|
|
238
|
+
? ["changed", "unchanged"].includes(result.outcome)
|
|
239
|
+
: result;
|
|
241
240
|
}
|
|
242
241
|
|
|
243
242
|
sweepExpired(now: Date, retryDelayMs: number) {
|
|
@@ -180,7 +180,20 @@ describePostgres("PostgreSQL durable execution", () => {
|
|
|
180
180
|
const execution = new DrizzleDurableExecutionRepository(drizzle(pool));
|
|
181
181
|
const claim = await execution.claim(claimInput("worker-a", base, 10_000));
|
|
182
182
|
if (!claim) throw new Error("任务未领取");
|
|
183
|
-
|
|
183
|
+
const running = await service.get(submitted.job.id);
|
|
184
|
+
if (!running) throw new Error("运行任务不存在");
|
|
185
|
+
const cancelInput = {
|
|
186
|
+
jobId: submitted.job.id,
|
|
187
|
+
expectedStatus: running.job.status,
|
|
188
|
+
expectedVersion: running.job.updatedAt,
|
|
189
|
+
now: base,
|
|
190
|
+
} as const;
|
|
191
|
+
expect(await execution.requestCancel(cancelInput)).toEqual({
|
|
192
|
+
outcome: "changed",
|
|
193
|
+
});
|
|
194
|
+
expect(await execution.requestCancel(cancelInput)).toEqual({
|
|
195
|
+
outcome: "unchanged",
|
|
196
|
+
});
|
|
184
197
|
expect(await execution.heartbeat(claim, base, 10_000)).toMatchObject({
|
|
185
198
|
ok: true,
|
|
186
199
|
cancelRequested: true,
|
|
@@ -190,11 +203,16 @@ describePostgres("PostgreSQL durable execution", () => {
|
|
|
190
203
|
).toBe(1);
|
|
191
204
|
expect((await service.get(submitted.job.id))?.job.status).toBe("cancelled");
|
|
192
205
|
|
|
206
|
+
const cancelled = await service.get(submitted.job.id);
|
|
207
|
+
if (!cancelled) throw new Error("取消任务不存在");
|
|
193
208
|
const recovery = {
|
|
194
209
|
jobId: submitted.job.id,
|
|
195
210
|
requestedBy: "ops_user",
|
|
196
211
|
reason: "确认下游依赖已经恢复",
|
|
197
212
|
idempotencyKey: "pg-recovery-1",
|
|
213
|
+
expectedAttempt: 1,
|
|
214
|
+
expectedStatus: "cancelled" as const,
|
|
215
|
+
expectedVersion: cancelled.job.updatedAt,
|
|
198
216
|
now: new Date(base.getTime() + 2_000),
|
|
199
217
|
};
|
|
200
218
|
const results = await Promise.all([
|
|
@@ -90,10 +90,12 @@ export function mapProgress(
|
|
|
90
90
|
export function mapArtifact(
|
|
91
91
|
row: typeof durableJobArtifacts.$inferSelect,
|
|
92
92
|
): DurableJobArtifact {
|
|
93
|
+
const { metadata, ...artifact } = row;
|
|
93
94
|
return {
|
|
94
|
-
...
|
|
95
|
+
...artifact,
|
|
95
96
|
stageId: row.stageId ?? undefined,
|
|
96
97
|
artifactRef: artifactRef(row.artifactRef),
|
|
98
|
+
...(metadata ? { metadata } : {}),
|
|
97
99
|
expiresAt: iso(row.expiresAt),
|
|
98
100
|
createdAt: row.createdAt.toISOString(),
|
|
99
101
|
};
|
|
@@ -6,14 +6,48 @@ import {
|
|
|
6
6
|
durableJobStages,
|
|
7
7
|
durableJobs,
|
|
8
8
|
} from "@southwind-ai/database";
|
|
9
|
-
import { and, eq, lte, notInArray, or } from "drizzle-orm";
|
|
9
|
+
import { and, eq, lte, max, notInArray, or } from "drizzle-orm";
|
|
10
10
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
CancelDurableJob,
|
|
13
|
+
ManualJobRecovery,
|
|
14
|
+
} from "./execution-repository.js";
|
|
12
15
|
import { mapRecovery } from "./drizzle-mappers.js";
|
|
13
16
|
|
|
14
17
|
export class DrizzleDurableRecoveryRepository {
|
|
15
18
|
constructor(private readonly db: NodePgDatabase) {}
|
|
16
19
|
|
|
20
|
+
async requestCancel(input: CancelDurableJob) {
|
|
21
|
+
return this.db.transaction(async (tx) => {
|
|
22
|
+
const [job] = await tx
|
|
23
|
+
.select()
|
|
24
|
+
.from(durableJobs)
|
|
25
|
+
.where(eq(durableJobs.id, input.jobId))
|
|
26
|
+
.for("update")
|
|
27
|
+
.limit(1);
|
|
28
|
+
if (!job) return { outcome: "not-found" } as const;
|
|
29
|
+
if (job.status === "cancelled" || job.cancelRequestedAt) {
|
|
30
|
+
return { outcome: "unchanged" } as const;
|
|
31
|
+
}
|
|
32
|
+
if (
|
|
33
|
+
!["queued", "running"].includes(job.status) ||
|
|
34
|
+
(input.expectedStatus && job.status !== input.expectedStatus) ||
|
|
35
|
+
(input.expectedVersion &&
|
|
36
|
+
job.updatedAt.toISOString() !== input.expectedVersion)
|
|
37
|
+
) {
|
|
38
|
+
return { outcome: "conflict" } as const;
|
|
39
|
+
}
|
|
40
|
+
if (job.status === "queued") await cancelRemaining(tx, job.id, input.now);
|
|
41
|
+
else {
|
|
42
|
+
await tx
|
|
43
|
+
.update(durableJobs)
|
|
44
|
+
.set({ cancelRequestedAt: input.now, updatedAt: input.now })
|
|
45
|
+
.where(eq(durableJobs.id, job.id));
|
|
46
|
+
}
|
|
47
|
+
return { outcome: "changed" } as const;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
17
51
|
async sweepExpired(now: Date, retryDelayMs: number) {
|
|
18
52
|
return this.db.transaction(async (tx) => {
|
|
19
53
|
const rows = await tx
|
|
@@ -139,6 +173,19 @@ export class DrizzleDurableRecoveryRepository {
|
|
|
139
173
|
if (!["failed", "cancelled"].includes(job.status)) {
|
|
140
174
|
throw new Error("只有失败或已取消任务可以人工恢复");
|
|
141
175
|
}
|
|
176
|
+
const [{ attempt = 0 } = { attempt: 0 }] = await tx
|
|
177
|
+
.select({ attempt: max(durableJobStages.currentAttempt) })
|
|
178
|
+
.from(durableJobStages)
|
|
179
|
+
.where(eq(durableJobStages.jobId, job.id));
|
|
180
|
+
if (
|
|
181
|
+
(input.expectedStatus && job.status !== input.expectedStatus) ||
|
|
182
|
+
(input.expectedVersion &&
|
|
183
|
+
job.updatedAt.toISOString() !== input.expectedVersion) ||
|
|
184
|
+
(input.expectedAttempt !== undefined &&
|
|
185
|
+
(attempt || 0) !== input.expectedAttempt)
|
|
186
|
+
) {
|
|
187
|
+
throw new Error("任务状态已变化");
|
|
188
|
+
}
|
|
142
189
|
const [activeLease] = await tx
|
|
143
190
|
.select({ id: durableJobLeases.id })
|
|
144
191
|
.from(durableJobLeases)
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ClaimedDurableJob,
|
|
3
3
|
DurableJobArtifact,
|
|
4
4
|
DurableJobRecovery,
|
|
5
|
+
DurableJobStatus,
|
|
5
6
|
DurableLeaseIdentity,
|
|
6
7
|
} from "@southwind-ai/jobs";
|
|
7
8
|
|
|
@@ -32,6 +33,9 @@ export interface ManualJobRecovery {
|
|
|
32
33
|
requestedBy: string;
|
|
33
34
|
reason: string;
|
|
34
35
|
idempotencyKey: string;
|
|
36
|
+
expectedAttempt?: number;
|
|
37
|
+
expectedStatus?: DurableJobStatus;
|
|
38
|
+
expectedVersion?: string;
|
|
35
39
|
now: Date;
|
|
36
40
|
}
|
|
37
41
|
|
|
@@ -40,6 +44,17 @@ export interface ManualJobRecoveryResult {
|
|
|
40
44
|
recovered: boolean;
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
export interface CancelDurableJob {
|
|
48
|
+
jobId: string;
|
|
49
|
+
expectedStatus?: DurableJobStatus;
|
|
50
|
+
expectedVersion?: string;
|
|
51
|
+
now: Date;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type DurableJobControlResult =
|
|
55
|
+
| { outcome: "changed" | "unchanged" }
|
|
56
|
+
| { outcome: "not-found" | "conflict" };
|
|
57
|
+
|
|
43
58
|
export interface DurableExecutionRepository {
|
|
44
59
|
claim(input: ClaimDurableJob): Promise<ClaimedDurableJob | undefined>;
|
|
45
60
|
heartbeat(
|
|
@@ -66,7 +81,9 @@ export interface DurableExecutionRepository {
|
|
|
66
81
|
failure: FailDurableJob,
|
|
67
82
|
now: Date,
|
|
68
83
|
): Promise<LeaseResult>;
|
|
84
|
+
/** @deprecated 兼容平台内部旧调用;模块 Port 必须使用 CAS 输入。 */
|
|
69
85
|
requestCancel(jobId: string, now: Date): Promise<boolean>;
|
|
86
|
+
requestCancel(input: CancelDurableJob): Promise<DurableJobControlResult>;
|
|
70
87
|
sweepExpired(now: Date, retryDelayMs: number): Promise<number>;
|
|
71
88
|
recover(input: ManualJobRecovery): Promise<ManualJobRecoveryResult>;
|
|
72
89
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DurableJob,
|
|
3
|
+
DurableJobLease,
|
|
4
|
+
DurableJobRecovery,
|
|
5
|
+
DurableJobStage,
|
|
6
|
+
} from "@southwind-ai/jobs";
|
|
7
|
+
import type {
|
|
8
|
+
CancelDurableJob,
|
|
9
|
+
ManualJobRecovery,
|
|
10
|
+
} from "./execution-repository.js";
|
|
11
|
+
|
|
12
|
+
interface InMemoryControlFacts {
|
|
13
|
+
jobs: Map<string, DurableJob>;
|
|
14
|
+
stages: Map<string, DurableJobStage>;
|
|
15
|
+
leases: Map<string, DurableJobLease>;
|
|
16
|
+
recoveries: Map<string, DurableJobRecovery>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function requestInMemoryCancel(
|
|
20
|
+
facts: InMemoryControlFacts,
|
|
21
|
+
input: CancelDurableJob,
|
|
22
|
+
) {
|
|
23
|
+
const job = facts.jobs.get(input.jobId);
|
|
24
|
+
if (!job) return { outcome: "not-found" } as const;
|
|
25
|
+
if (job.status === "cancelled" || job.cancelRequestedAt) {
|
|
26
|
+
return { outcome: "unchanged" } as const;
|
|
27
|
+
}
|
|
28
|
+
if (
|
|
29
|
+
["succeeded", "failed"].includes(job.status) ||
|
|
30
|
+
(input.expectedStatus && job.status !== input.expectedStatus) ||
|
|
31
|
+
(input.expectedVersion && job.updatedAt !== input.expectedVersion)
|
|
32
|
+
) {
|
|
33
|
+
return { outcome: "conflict" } as const;
|
|
34
|
+
}
|
|
35
|
+
if (job.status === "queued") cancelInMemoryJob(facts, job, input.now);
|
|
36
|
+
else {
|
|
37
|
+
job.cancelRequestedAt = input.now.toISOString();
|
|
38
|
+
job.updatedAt = input.now.toISOString();
|
|
39
|
+
}
|
|
40
|
+
return { outcome: "changed" } as const;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function recoverInMemoryJob(
|
|
44
|
+
facts: InMemoryControlFacts,
|
|
45
|
+
input: ManualJobRecovery,
|
|
46
|
+
) {
|
|
47
|
+
const existing = [...facts.recoveries.values()].find(
|
|
48
|
+
(item) =>
|
|
49
|
+
item.jobId === input.jobId &&
|
|
50
|
+
item.idempotencyKey === input.idempotencyKey,
|
|
51
|
+
);
|
|
52
|
+
if (existing)
|
|
53
|
+
return { recovery: structuredClone(existing), recovered: false };
|
|
54
|
+
const job = facts.jobs.get(input.jobId);
|
|
55
|
+
if (!job) throw new Error("任务不存在");
|
|
56
|
+
const stages = jobStages(facts, job.id);
|
|
57
|
+
const currentAttempt = Math.max(
|
|
58
|
+
0,
|
|
59
|
+
...stages.map((stage) => stage.currentAttempt),
|
|
60
|
+
);
|
|
61
|
+
if (
|
|
62
|
+
(input.expectedStatus && job.status !== input.expectedStatus) ||
|
|
63
|
+
(input.expectedVersion && job.updatedAt !== input.expectedVersion) ||
|
|
64
|
+
(input.expectedAttempt !== undefined &&
|
|
65
|
+
currentAttempt !== input.expectedAttempt)
|
|
66
|
+
)
|
|
67
|
+
throw new Error("任务状态已变化");
|
|
68
|
+
if (!["failed", "cancelled"].includes(job.status))
|
|
69
|
+
throw new Error("只有失败或已取消任务可以人工恢复");
|
|
70
|
+
if (
|
|
71
|
+
[...facts.leases.values()].some(
|
|
72
|
+
(lease) => lease.jobId === job.id && lease.status === "active",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
throw new Error("任务仍有活跃租约,不能人工恢复");
|
|
76
|
+
const recovery: DurableJobRecovery = {
|
|
77
|
+
id: Bun.randomUUIDv7(),
|
|
78
|
+
jobId: job.id,
|
|
79
|
+
fromStatus: job.status,
|
|
80
|
+
requestedBy: input.requestedBy,
|
|
81
|
+
reason: input.reason,
|
|
82
|
+
idempotencyKey: input.idempotencyKey,
|
|
83
|
+
createdAt: input.now.toISOString(),
|
|
84
|
+
};
|
|
85
|
+
facts.recoveries.set(recovery.id, recovery);
|
|
86
|
+
for (const stage of stages) {
|
|
87
|
+
if (stage.status !== "succeeded") {
|
|
88
|
+
Object.assign(stage, {
|
|
89
|
+
status: "pending",
|
|
90
|
+
startedAt: undefined,
|
|
91
|
+
finishedAt: undefined,
|
|
92
|
+
updatedAt: input.now.toISOString(),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
Object.assign(job, {
|
|
97
|
+
status: "queued",
|
|
98
|
+
availableAt: input.now.toISOString(),
|
|
99
|
+
cancelRequestedAt: undefined,
|
|
100
|
+
finishedAt: undefined,
|
|
101
|
+
updatedAt: input.now.toISOString(),
|
|
102
|
+
});
|
|
103
|
+
return { recovery: structuredClone(recovery), recovered: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function cancelInMemoryJob(
|
|
107
|
+
facts: Pick<InMemoryControlFacts, "stages">,
|
|
108
|
+
job: DurableJob,
|
|
109
|
+
now: Date,
|
|
110
|
+
) {
|
|
111
|
+
for (const stage of jobStages(facts, job.id)) {
|
|
112
|
+
if (stage.status !== "succeeded") {
|
|
113
|
+
Object.assign(stage, {
|
|
114
|
+
status: "cancelled",
|
|
115
|
+
finishedAt: now.toISOString(),
|
|
116
|
+
updatedAt: now.toISOString(),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
Object.assign(job, {
|
|
121
|
+
status: "cancelled",
|
|
122
|
+
cancelRequestedAt: job.cancelRequestedAt || now.toISOString(),
|
|
123
|
+
finishedAt: now.toISOString(),
|
|
124
|
+
updatedAt: now.toISOString(),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function jobStages(
|
|
129
|
+
facts: Pick<InMemoryControlFacts, "stages">,
|
|
130
|
+
jobId: string,
|
|
131
|
+
) {
|
|
132
|
+
return [...facts.stages.values()]
|
|
133
|
+
.filter((stage) => stage.jobId === jobId)
|
|
134
|
+
.sort((left, right) => left.ordinal - right.ordinal);
|
|
135
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
CancelDurableJob,
|
|
2
3
|
ClaimDurableJob,
|
|
4
|
+
DurableJobControlResult,
|
|
3
5
|
DurableExecutionRepository,
|
|
4
6
|
FailDurableJob,
|
|
5
7
|
ManualJobRecovery,
|
|
@@ -17,6 +19,11 @@ import {
|
|
|
17
19
|
publishInMemoryArtifact,
|
|
18
20
|
validateInMemoryLease,
|
|
19
21
|
} from "./in-memory-lease.js";
|
|
22
|
+
import {
|
|
23
|
+
cancelInMemoryJob,
|
|
24
|
+
recoverInMemoryJob,
|
|
25
|
+
requestInMemoryCancel,
|
|
26
|
+
} from "./in-memory-control.js";
|
|
20
27
|
|
|
21
28
|
export class InMemoryDurableExecutionRepository
|
|
22
29
|
extends InMemoryDurableJobRepository
|
|
@@ -160,17 +167,22 @@ export class InMemoryDurableExecutionRepository
|
|
|
160
167
|
);
|
|
161
168
|
}
|
|
162
169
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
170
|
+
requestCancel(
|
|
171
|
+
input: CancelDurableJob,
|
|
172
|
+
): Promise<DurableJobControlResult>;
|
|
173
|
+
requestCancel(jobId: string, now: Date): Promise<boolean>;
|
|
174
|
+
async requestCancel(
|
|
175
|
+
input: CancelDurableJob | string,
|
|
176
|
+
now?: Date,
|
|
177
|
+
) {
|
|
178
|
+
const legacy = typeof input === "string";
|
|
179
|
+
const result = requestInMemoryCancel(
|
|
180
|
+
this.controlFacts(),
|
|
181
|
+
legacy ? { jobId: input, now: now! } : input,
|
|
182
|
+
);
|
|
183
|
+
return legacy
|
|
184
|
+
? ["changed", "unchanged"].includes(result.outcome)
|
|
185
|
+
: result;
|
|
174
186
|
}
|
|
175
187
|
|
|
176
188
|
async sweepExpired(now: Date, retryDelayMs: number) {
|
|
@@ -200,45 +212,7 @@ export class InMemoryDurableExecutionRepository
|
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
async recover(input: ManualJobRecovery) {
|
|
203
|
-
|
|
204
|
-
(item) =>
|
|
205
|
-
item.jobId === input.jobId &&
|
|
206
|
-
item.idempotencyKey === input.idempotencyKey,
|
|
207
|
-
);
|
|
208
|
-
if (existing)
|
|
209
|
-
return { recovery: structuredClone(existing), recovered: false };
|
|
210
|
-
const job = this.jobs.get(input.jobId);
|
|
211
|
-
if (!job || !["failed", "cancelled"].includes(job.status)) {
|
|
212
|
-
throw new Error("只有失败或已取消任务可以人工恢复");
|
|
213
|
-
}
|
|
214
|
-
const recovery = {
|
|
215
|
-
id: Bun.randomUUIDv7(),
|
|
216
|
-
jobId: job.id,
|
|
217
|
-
fromStatus: job.status,
|
|
218
|
-
requestedBy: input.requestedBy,
|
|
219
|
-
reason: input.reason,
|
|
220
|
-
idempotencyKey: input.idempotencyKey,
|
|
221
|
-
createdAt: input.now.toISOString(),
|
|
222
|
-
} as const;
|
|
223
|
-
this.recoveries.set(recovery.id, recovery);
|
|
224
|
-
for (const stage of this.jobStages(job.id)) {
|
|
225
|
-
if (stage.status !== "succeeded") {
|
|
226
|
-
Object.assign(stage, {
|
|
227
|
-
status: "pending",
|
|
228
|
-
startedAt: undefined,
|
|
229
|
-
finishedAt: undefined,
|
|
230
|
-
updatedAt: input.now.toISOString(),
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
Object.assign(job, {
|
|
235
|
-
status: "queued",
|
|
236
|
-
availableAt: input.now.toISOString(),
|
|
237
|
-
cancelRequestedAt: undefined,
|
|
238
|
-
finishedAt: undefined,
|
|
239
|
-
updatedAt: input.now.toISOString(),
|
|
240
|
-
});
|
|
241
|
-
return { recovery: structuredClone(recovery), recovered: true };
|
|
215
|
+
return recoverInMemoryJob(this.controlFacts(), input);
|
|
242
216
|
}
|
|
243
217
|
|
|
244
218
|
private settleFailure(
|
|
@@ -317,20 +291,15 @@ export class InMemoryDurableExecutionRepository
|
|
|
317
291
|
}
|
|
318
292
|
|
|
319
293
|
private cancelJob(job: DurableJob, now: Date) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
status: "cancelled",
|
|
331
|
-
cancelRequestedAt: job.cancelRequestedAt || now.toISOString(),
|
|
332
|
-
finishedAt: now.toISOString(),
|
|
333
|
-
updatedAt: now.toISOString(),
|
|
334
|
-
});
|
|
294
|
+
cancelInMemoryJob({ stages: this.stages }, job, now);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private controlFacts() {
|
|
298
|
+
return {
|
|
299
|
+
jobs: this.jobs,
|
|
300
|
+
stages: this.stages,
|
|
301
|
+
leases: this.leases,
|
|
302
|
+
recoveries: this.recoveries,
|
|
303
|
+
};
|
|
335
304
|
}
|
|
336
305
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
CancelDurableJob,
|
|
2
3
|
DurableExecutionRepository,
|
|
3
4
|
LeaseResult,
|
|
4
5
|
ManualJobRecovery,
|
|
@@ -35,6 +36,7 @@ export class DurableJobWorker {
|
|
|
35
36
|
private readonly retryDelayMs: number;
|
|
36
37
|
private readonly now: () => Date;
|
|
37
38
|
private readonly contextualize?: DurableWorkerOptions["contextualize"];
|
|
39
|
+
private readonly activeRuns = new Map<string, AbortController>();
|
|
38
40
|
|
|
39
41
|
constructor(
|
|
40
42
|
private readonly repository: DurableExecutionRepository,
|
|
@@ -72,8 +74,20 @@ export class DurableJobWorker {
|
|
|
72
74
|
return this.execute(claim, handler);
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
cancel(jobId: string) {
|
|
76
|
-
|
|
77
|
+
async cancel(jobId: string) {
|
|
78
|
+
const result = await this.controlCancel({ jobId });
|
|
79
|
+
return ["changed", "unchanged"].includes(result.outcome);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async controlCancel(input: Omit<CancelDurableJob, "now">) {
|
|
83
|
+
const result = await this.repository.requestCancel({
|
|
84
|
+
...input,
|
|
85
|
+
now: this.now(),
|
|
86
|
+
});
|
|
87
|
+
if (["changed", "unchanged"].includes(result.outcome)) {
|
|
88
|
+
this.activeRuns.get(input.jobId)?.abort("cancelled");
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
77
91
|
}
|
|
78
92
|
|
|
79
93
|
recover(input: Omit<ManualJobRecovery, "now">) {
|
|
@@ -91,6 +105,7 @@ export class DurableJobWorker {
|
|
|
91
105
|
handler: RegisteredDurableHandler,
|
|
92
106
|
): Promise<DurableRunResult> {
|
|
93
107
|
const abort = new AbortController();
|
|
108
|
+
this.activeRuns.set(claim.jobId, abort);
|
|
94
109
|
const identity = identityOf(claim);
|
|
95
110
|
let rejectControl: (error: Error) => void = () => undefined;
|
|
96
111
|
const control = new Promise<never>((_, reject) => {
|
|
@@ -181,7 +196,13 @@ export class DurableJobWorker {
|
|
|
181
196
|
if (error instanceof LeaseLostError) {
|
|
182
197
|
return { status: "lease-lost", jobId: claim.jobId };
|
|
183
198
|
}
|
|
184
|
-
const failure = classifyFailure(
|
|
199
|
+
const failure = classifyFailure(
|
|
200
|
+
abort.signal.reason === "cancelled"
|
|
201
|
+
? new JobCancelledError("任务已请求取消")
|
|
202
|
+
: error,
|
|
203
|
+
this.now(),
|
|
204
|
+
this.retryDelayMs,
|
|
205
|
+
);
|
|
185
206
|
const result = await this.repository.fail(identity, failure, this.now());
|
|
186
207
|
if (!result.ok) return { status: "lease-lost", jobId: claim.jobId };
|
|
187
208
|
return {
|
|
@@ -189,6 +210,9 @@ export class DurableJobWorker {
|
|
|
189
210
|
jobId: claim.jobId,
|
|
190
211
|
};
|
|
191
212
|
} finally {
|
|
213
|
+
if (this.activeRuns.get(claim.jobId) === abort) {
|
|
214
|
+
this.activeRuns.delete(claim.jobId);
|
|
215
|
+
}
|
|
192
216
|
clearInterval(interval);
|
|
193
217
|
clearTimeout(timeout);
|
|
194
218
|
}
|
|
@@ -22,6 +22,19 @@ export function exportTarget(
|
|
|
22
22
|
fieldKeys: policy.fields.map(({ fieldKey }) => fieldKey),
|
|
23
23
|
approvalRequired: registration.export?.approvalRequired,
|
|
24
24
|
retentionSeconds: registration.export?.retentionSeconds,
|
|
25
|
+
...(registration.export?.renderers
|
|
26
|
+
? {
|
|
27
|
+
renderers: registration.export.renderers.map((renderer) => ({
|
|
28
|
+
formatKey: renderer.formatKey,
|
|
29
|
+
templateKey: renderer.templateKey,
|
|
30
|
+
templateVersion: renderer.templateVersion,
|
|
31
|
+
inputSchemaKey: renderer.inputSchemaKey,
|
|
32
|
+
contentType: renderer.contentType,
|
|
33
|
+
filenameExtension: renderer.filenameExtension,
|
|
34
|
+
...renderer.limits,
|
|
35
|
+
})),
|
|
36
|
+
}
|
|
37
|
+
: {}),
|
|
25
38
|
};
|
|
26
39
|
}
|
|
27
40
|
|
|
@@ -29,16 +42,23 @@ export function parseCreate(
|
|
|
29
42
|
body: unknown,
|
|
30
43
|
registration: ModuleGovernedResourceRegistration,
|
|
31
44
|
) {
|
|
32
|
-
const value = strictObject(body, [
|
|
45
|
+
const value = strictObject(body, [
|
|
46
|
+
"idempotencyKey",
|
|
47
|
+
"reason",
|
|
48
|
+
"query",
|
|
49
|
+
"formatKey",
|
|
50
|
+
]);
|
|
33
51
|
try {
|
|
34
52
|
const query = registration.export!.validateQuery(value.query ?? {});
|
|
35
53
|
if (!isJsonValue(query) || JSON.stringify(query).length > 64_000) {
|
|
36
54
|
throw new Error("invalid query");
|
|
37
55
|
}
|
|
56
|
+
const formatKey = optionalString(value.formatKey);
|
|
38
57
|
return {
|
|
39
58
|
idempotencyKey: requiredString(value.idempotencyKey),
|
|
40
59
|
reason: requiredString(value.reason),
|
|
41
60
|
query,
|
|
61
|
+
...(formatKey === undefined ? {} : { formatKey }),
|
|
42
62
|
};
|
|
43
63
|
} catch (error) {
|
|
44
64
|
if (error instanceof GovernedExportServiceError) throw error;
|
|
@@ -115,6 +135,9 @@ export function project(request: GovernedExportRequest) {
|
|
|
115
135
|
expiresAt: request.expiresAt,
|
|
116
136
|
createdAt: request.createdAt,
|
|
117
137
|
updatedAt: request.updatedAt,
|
|
138
|
+
formatKey: request.renderer.formatKey,
|
|
139
|
+
templateKey: request.renderer.templateKey,
|
|
140
|
+
templateVersion: request.renderer.templateVersion,
|
|
118
141
|
};
|
|
119
142
|
}
|
|
120
143
|
|
|
@@ -146,7 +169,3 @@ export function requirePolicy(
|
|
|
146
169
|
export function stripApiPrefix(path: string) {
|
|
147
170
|
return path.slice("/api".length);
|
|
148
171
|
}
|
|
149
|
-
|
|
150
|
-
export function filename(resourceType: string) {
|
|
151
|
-
return `governed-${resourceType.replaceAll(/[^a-zA-Z0-9_-]/g, "-")}.ndjson`;
|
|
152
|
-
}
|