create-windy 0.6.14 → 0.6.16
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 +1008 -61
- package/package.json +1 -1
- package/template/.agents/skills/windy-business-module/SKILL.md +58 -8
- package/template/.windy-template.json +2 -2
- package/template/apps/server/src/agent/client-session/attempt-authority.test.ts +162 -0
- package/template/apps/server/src/agent/client-session/attempt-authority.ts +191 -0
- package/template/apps/server/src/agent/client-session/bootstrap.ts +27 -11
- package/template/apps/server/src/agent/client-session/completion.ts +75 -24
- package/template/apps/server/src/agent/client-session/contracts.ts +23 -0
- package/template/apps/server/src/agent/client-session/creation.ts +103 -0
- package/template/apps/server/src/agent/client-session/digest.ts +11 -1
- package/template/apps/server/src/agent/client-session/drizzle-records.ts +150 -0
- package/template/apps/server/src/agent/client-session/drizzle-repository.integration.test.ts +30 -3
- package/template/apps/server/src/agent/client-session/drizzle-repository.ts +23 -132
- package/template/apps/server/src/agent/client-session/finalization.ts +99 -0
- package/template/apps/server/src/agent/client-session/host-auth.ts +22 -10
- package/template/apps/server/src/agent/client-session/host-revocation-composition.test.ts +77 -0
- package/template/apps/server/src/agent/client-session/host-revocation-composition.ts +28 -0
- package/template/apps/server/src/agent/client-session/launch-authority.contract.test.ts +21 -0
- package/template/apps/server/src/agent/client-session/manifest-admission.ts +11 -0
- package/template/apps/server/src/agent/client-session/service.test-support.ts +22 -0
- package/template/apps/server/src/agent/client-session/service.ts +104 -134
- package/template/apps/server/src/agent/client-session/terminal-digest.test.ts +23 -0
- package/template/apps/server/src/agent/client-workflow-artifact/active-blob-references.ts +8 -0
- package/template/apps/server/src/agent/client-workflow-artifact/artifact-builder.ts +60 -0
- package/template/apps/server/src/agent/client-workflow-artifact/authority-current.ts +59 -0
- package/template/apps/server/src/agent/client-workflow-artifact/binding-registry.test.ts +189 -0
- package/template/apps/server/src/agent/client-workflow-artifact/binding-registry.ts +153 -0
- package/template/apps/server/src/agent/client-workflow-artifact/bootstrap.ts +77 -0
- package/template/apps/server/src/agent/client-workflow-artifact/cancellation.ts +47 -0
- package/template/apps/server/src/agent/client-workflow-artifact/completed-recovery.ts +31 -0
- package/template/apps/server/src/agent/client-workflow-artifact/contracts.ts +104 -0
- package/template/apps/server/src/agent/client-workflow-artifact/drizzle-locks.ts +30 -0
- package/template/apps/server/src/agent/client-workflow-artifact/drizzle-mapper.ts +138 -0
- package/template/apps/server/src/agent/client-workflow-artifact/drizzle-repository.ts +346 -0
- package/template/apps/server/src/agent/client-workflow-artifact/error.ts +21 -0
- package/template/apps/server/src/agent/client-workflow-artifact/handler.ts +167 -0
- package/template/apps/server/src/agent/client-workflow-artifact/host-revocation.ts +29 -0
- package/template/apps/server/src/agent/client-workflow-artifact/in-memory-repository.ts +232 -0
- package/template/apps/server/src/agent/client-workflow-artifact/job-port.ts +101 -0
- package/template/apps/server/src/agent/client-workflow-artifact/presentation.ts +41 -0
- package/template/apps/server/src/agent/client-workflow-artifact/recovery.contract.test.ts +253 -0
- package/template/apps/server/src/agent/client-workflow-artifact/release-binding.ts +85 -0
- package/template/apps/server/src/agent/client-workflow-artifact/repository.test.ts +204 -0
- package/template/apps/server/src/agent/client-workflow-artifact/repository.ts +97 -0
- package/template/apps/server/src/agent/client-workflow-artifact/routes-handler.contract.test.ts +201 -0
- package/template/apps/server/src/agent/client-workflow-artifact/routes.ts +161 -0
- package/template/apps/server/src/agent/client-workflow-artifact/runtime-composition.ts +101 -0
- package/template/apps/server/src/agent/client-workflow-artifact/scope-authority.ts +39 -0
- package/template/apps/server/src/agent/client-workflow-artifact/service-authorization.ts +117 -0
- package/template/apps/server/src/agent/client-workflow-artifact/service-support.ts +28 -0
- package/template/apps/server/src/agent/client-workflow-artifact/service.contract.test.ts +345 -0
- package/template/apps/server/src/agent/client-workflow-artifact/service.ts +350 -0
- package/template/apps/server/src/agent/client-workflow-artifact/session-boundary.ts +39 -0
- package/template/apps/server/src/agent/client-workflow-artifact/source-access.contract.test.ts +98 -0
- package/template/apps/server/src/agent/client-workflow-artifact/source-access.ts +80 -0
- package/template/apps/server/src/agent/client-workflow-artifact/takeover.ts +122 -0
- package/template/apps/server/src/agent/client-workflow-artifact/task-authority.contract.test.ts +97 -0
- package/template/apps/server/src/agent/client-workflow-artifact/task-authority.ts +147 -0
- package/template/apps/server/src/agent/client-workflow-artifact/terminal-authority.contract.test.ts +100 -0
- package/template/apps/server/src/agent/client-workflow-artifact/terminal-replay.ts +31 -0
- package/template/apps/server/src/agent/client-workflow-artifact/terminal-seal.ts +211 -0
- package/template/apps/server/src/agent/client-workflow-artifact/workflow-artifact.test-support.ts +173 -0
- package/template/apps/server/src/agent/client-workflow-artifact/workflow-loader.contract.test.ts +254 -0
- package/template/apps/server/src/agent/client-workflow-artifact/workflow-loader.ts +285 -0
- package/template/apps/server/src/agent/run-facade.test-support.ts +17 -4
- package/template/apps/server/src/agent/run-facade.test.ts +16 -0
- package/template/apps/server/src/agent/run-session-authorization.ts +12 -10
- package/template/apps/server/src/agent/runtime-bootstrap-input.ts +5 -1
- package/template/apps/server/src/agent/runtime-bootstrap.ts +33 -33
- package/template/apps/server/src/agent/runtime-public-ports.ts +40 -0
- package/template/apps/server/src/agent/session-invalidation/contracts.ts +25 -26
- package/template/apps/server/src/agent/session-projection-access.ts +31 -19
- package/template/apps/server/src/agent/unavailable-routes.ts +21 -0
- package/template/apps/server/src/agent/workflow-runtime-bootstrap.ts +2 -0
- package/template/apps/server/src/agent-workflow/artifact-source-authorization.ts +12 -6
- package/template/apps/server/src/agent-workflow/command-facade.ts +70 -11
- package/template/apps/server/src/agent-workflow/drizzle-artifact-port.ts +9 -9
- package/template/apps/server/src/agent-workflow/drizzle-delegation-completion.ts +1 -1
- package/template/apps/server/src/agent-workflow/platform-node-adapters.ts +14 -6
- package/template/apps/server/src/agent-workflow/project-launch-authority.contract.test.ts +53 -0
- package/template/apps/server/src/agent-workflow/project-launch-authority.ts +92 -0
- package/template/apps/server/src/agent-workflow/runtime-composition.ts +30 -14
- package/template/apps/server/src/app/agent-runtime-composition.ts +6 -1
- package/template/apps/server/src/app/client-agent-workflow-artifact-binding.ts +14 -0
- package/template/apps/server/src/application-services.ts +6 -0
- package/template/apps/server/src/data-access/scope-containment.test.ts +31 -0
- package/template/apps/server/src/data-access/scope-containment.ts +48 -0
- package/template/apps/server/src/jobs/artifact-publication.test.ts +192 -0
- package/template/apps/server/src/jobs/artifact-publication.ts +56 -0
- package/template/apps/server/src/jobs/artifact-publisher.ts +101 -0
- package/template/apps/server/src/jobs/drizzle-artifact.ts +73 -1
- package/template/apps/server/src/jobs/drizzle-execution-repository.ts +14 -1
- package/template/apps/server/src/jobs/drizzle-execution.integration.test.ts +65 -0
- package/template/apps/server/src/jobs/drizzle-repository.integration.test.ts +1 -0
- package/template/apps/server/src/jobs/execution-repository.ts +28 -0
- package/template/apps/server/src/jobs/execution.test.ts +3 -1
- package/template/apps/server/src/jobs/in-memory-execution.ts +16 -0
- package/template/apps/server/src/jobs/in-memory-lease.ts +45 -1
- package/template/apps/server/src/jobs/legacy-adapter.integration.test.ts +2 -0
- package/template/apps/server/src/jobs/postgres-cleanup-contract.test.ts +58 -0
- package/template/apps/server/src/jobs/worker.ts +8 -14
- package/template/apps/server/src/module-host/background-ai-unavailable.ts +18 -0
- package/template/apps/server/src/module-host/durable-context.ts +19 -22
- package/template/apps/server/src/module-host/implementation-completeness.test.ts +80 -1
- package/template/apps/server/src/module-host/implementation-completeness.ts +28 -1
- package/template/apps/server/src/module-host/request-context.ts +32 -8
- package/template/apps/server/src/module-host/storage-port.ts +3 -0
- package/template/apps/server/src/module-host/workflow-input-artifact-port.test.ts +104 -0
- package/template/apps/server/src/module-host/workflow-input-artifact-port.ts +229 -0
- package/template/apps/server/src/module-host/workflow-input-materialization.test.ts +163 -0
- package/template/apps/server/src/module-host/workflow-input-materialization.ts +161 -0
- package/template/apps/server/src/workflow/drizzle-repository.integration.test.ts +1 -0
- package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.vue +2 -1
- package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.webtest.ts +16 -0
- package/template/docs/architecture/agent-workflow.md +13 -2
- package/template/docs/architecture/durable-jobs.md +7 -0
- package/template/docs/operations/agent-session-memory.md +2 -1
- package/template/docs/operations/agent-session-troubleshooting.md +87 -2
- package/template/docs/operations/agent-workflow-recovery.md +25 -14
- package/template/docs/platform/client-agent-session.md +148 -2
- package/template/packages/ai-client/package.json +2 -1
- package/template/packages/ai-client/src/client-agent/index.ts +1 -0
- package/template/packages/ai-client/src/client-agent/workflow-artifact/contracts.ts +85 -0
- package/template/packages/ai-client/src/client-agent/workflow-artifact/http.test.ts +49 -0
- package/template/packages/ai-client/src/client-agent/workflow-artifact/http.ts +82 -0
- package/template/packages/ai-client/src/client-agent/workflow-artifact/index.ts +3 -0
- package/template/packages/ai-client/src/client-agent/workflow-artifact/validation.test.ts +154 -0
- package/template/packages/ai-client/src/client-agent/workflow-artifact/validation.ts +332 -0
- package/template/packages/database/drizzle/0062_client_agent_workflow_artifact.sql +63 -0
- package/template/packages/database/drizzle/meta/0062_snapshot.json +14839 -0
- package/template/packages/database/drizzle/meta/_journal.json +7 -0
- package/template/packages/database/package.json +1 -1
- package/template/packages/database/src/agent-runtime-schema.test.ts +177 -0
- package/template/packages/database/src/schema/client-agent-workflow-artifact.ts +158 -0
- package/template/packages/database/src/schema/index.ts +1 -0
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs/src/handlers.test.ts +10 -0
- package/template/packages/jobs/src/handlers.ts +28 -11
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/ai-client-execution-policy.ts +6 -0
- package/template/packages/modules/src/manifest.ts +2 -0
- package/template/packages/modules/src/system-agent-audit-actions.ts +3 -0
- package/template/packages/modules/src/system-ai-api-bindings.ts +15 -0
- package/template/packages/modules/src/system-audit-actions.ts +15 -0
- package/template/packages/server-sdk/index.ts +52 -0
- package/template/packages/server-sdk/package.json +5 -1
- package/template/packages/server-sdk/src/client-agent-session.ts +13 -1
- package/template/packages/server-sdk/src/client-agent-workflow-artifact.contract.test.ts +113 -0
- package/template/packages/server-sdk/src/client-agent-workflow-artifact.ts +183 -0
- package/template/packages/server-sdk/src/durable-handler.ts +7 -0
- package/template/packages/server-sdk/src/request-context.ts +7 -0
- package/template/packages/server-sdk/src/workflow-input-artifact-port.ts +104 -0
- package/template/packages/server-sdk/src/workflow-input-artifact-validation.contract.test.ts +124 -0
- package/template/packages/server-sdk/src/workflow-input-artifact-validation.ts +347 -0
- package/template/packages/server-sdk/src/workflow-input-materialization-handler.ts +43 -0
- package/template/packages/storage/package.json +1 -1
- package/template/packages/storage/src/blob-store-put-once.test.ts +70 -0
- package/template/packages/storage/src/blob-store.ts +9 -0
- package/template/packages/storage/src/local-file-blob-put-once.ts +120 -0
- package/template/packages/storage/src/local-file-blob-store.ts +53 -1
- package/template/packages/storage/src/memory-blob-store.ts +73 -33
|
@@ -6,11 +6,14 @@ import {
|
|
|
6
6
|
projectAiScreenshotPageRegistryV2,
|
|
7
7
|
} from "./ai-screenshot-intake-binding.js";
|
|
8
8
|
import { projectClientAgentHostAuthAdapter } from "./client-agent-host-auth-binding.js";
|
|
9
|
+
import { projectClientAgentWorkflowArtifactBindings } from "./client-agent-workflow-artifact-binding.js";
|
|
9
10
|
|
|
10
11
|
export function composeApplicationAgentRuntime(
|
|
11
12
|
input: Omit<
|
|
12
13
|
AgentPlatformRuntimeInput,
|
|
13
|
-
|
|
14
|
+
| "clientAgentHostAuth"
|
|
15
|
+
| "clientAgentWorkflowArtifactBindings"
|
|
16
|
+
| "screenshotPageRegistry"
|
|
14
17
|
> & {
|
|
15
18
|
readonly bindAuthorizationInvalidation: (
|
|
16
19
|
target: AgentAuthorizationInvalidationTarget | undefined,
|
|
@@ -23,6 +26,8 @@ export function composeApplicationAgentRuntime(
|
|
|
23
26
|
const runtime = registerAgentPlatformRuntime({
|
|
24
27
|
...input,
|
|
25
28
|
clientAgentHostAuth: projectClientAgentHostAuthAdapter,
|
|
29
|
+
clientAgentWorkflowArtifactBindings:
|
|
30
|
+
projectClientAgentWorkflowArtifactBindings,
|
|
26
31
|
screenshotPageRegistry: input.platformConfig.enableAI
|
|
27
32
|
? projectAiScreenshotPageRegistryV2
|
|
28
33
|
: undefined,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ModuleClientAgentWorkflowArtifactProjectBinding } from "@southwind-ai/server-sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Project-owned Workflow -> PageAgent 绑定。Starter 默认空数组并 fail closed;
|
|
5
|
+
* 产品只为已审核、已发布的固定 Workflow Job node 增加静态绑定。Operation 必须声明
|
|
6
|
+
* `clientExecution.launchAuthority="workflow-artifact"`;binding 必须登记 Immutable
|
|
7
|
+
* canonical `releaseDigest`、scope containment contract v1,以及 project launch、client task、terminal
|
|
8
|
+
* authority version/digest。launch authority 从 Host 当前 scope 显式收窄;terminal authority
|
|
9
|
+
* 同时复验 Host 重授权并打开的 canonical input artifacts。project preset 使用
|
|
10
|
+
* runtime.agentWorkflowProjects;普通 routes 永远不能发布/启动受保护 Workflow。
|
|
11
|
+
* 发布 Release 或 authority contract 后必须重新审核并显式更新。
|
|
12
|
+
*/
|
|
13
|
+
export const projectClientAgentWorkflowArtifactBindings: readonly ModuleClientAgentWorkflowArtifactProjectBinding[] =
|
|
14
|
+
[];
|
|
@@ -43,6 +43,9 @@ import type { DurableHandlerRegistry } from "@southwind-ai/jobs";
|
|
|
43
43
|
// @windy-module system.storage begin
|
|
44
44
|
import { collectActiveBlobRefs } from "./storage/blob-reference-registry.js";
|
|
45
45
|
// @windy-module system.storage end
|
|
46
|
+
// @windy-module system.agent begin
|
|
47
|
+
import { clientWorkflowArtifactActiveBlobReferences } from "./agent/client-workflow-artifact/active-blob-references.js";
|
|
48
|
+
// @windy-module system.agent end
|
|
46
49
|
import type { createSystemRepositories } from "./system/seed.js";
|
|
47
50
|
// @windy-module system.data-governance begin
|
|
48
51
|
import { identityRouteConfigs } from "./system/identity-route-config.js";
|
|
@@ -175,6 +178,9 @@ export async function createApplicationServices(
|
|
|
175
178
|
// @windy-module system.bulk-data begin
|
|
176
179
|
bulkRepository,
|
|
177
180
|
// @windy-module system.bulk-data end
|
|
181
|
+
// @windy-module system.agent begin
|
|
182
|
+
...clientWorkflowArtifactActiveBlobReferences(persistence?.db),
|
|
183
|
+
// @windy-module system.agent end
|
|
178
184
|
],
|
|
179
185
|
now,
|
|
180
186
|
);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { AuthorizationScopeSnapshot } from "@southwind-ai/shared";
|
|
3
|
+
import { authorizationScopeContains } from "./scope-containment.js";
|
|
4
|
+
|
|
5
|
+
const base = {
|
|
6
|
+
tenantId: "single-tenant",
|
|
7
|
+
actorId: "actor-1",
|
|
8
|
+
roleIds: ["analyst"],
|
|
9
|
+
} satisfies Omit<AuthorizationScopeSnapshot, "grants">;
|
|
10
|
+
|
|
11
|
+
test("当前多案 custom scope 可包含冻结单案 scope", () => {
|
|
12
|
+
const current: AuthorizationScopeSnapshot = {
|
|
13
|
+
...base,
|
|
14
|
+
grants: [{ scope: "custom", dimension: "caseId", values: ["a", "b"] }],
|
|
15
|
+
};
|
|
16
|
+
const frozen: AuthorizationScopeSnapshot = {
|
|
17
|
+
...base,
|
|
18
|
+
grants: [{ scope: "custom", dimension: "caseId", values: ["a"] }],
|
|
19
|
+
};
|
|
20
|
+
expect(authorizationScopeContains(current, frozen)).toBe(true);
|
|
21
|
+
expect(
|
|
22
|
+
authorizationScopeContains(current, {
|
|
23
|
+
...frozen,
|
|
24
|
+
roleIds: ["role-no-longer-held"],
|
|
25
|
+
}),
|
|
26
|
+
).toBe(false);
|
|
27
|
+
expect(authorizationScopeContains(frozen, current)).toBe(false);
|
|
28
|
+
expect(
|
|
29
|
+
authorizationScopeContains({ ...current, actorId: "other-actor" }, frozen),
|
|
30
|
+
).toBe(false);
|
|
31
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthorizationScopeSnapshot,
|
|
3
|
+
PersistedScopeGrant,
|
|
4
|
+
} from "@southwind-ai/shared";
|
|
5
|
+
|
|
6
|
+
/** 当前 Actor scope 必须覆盖冻结 Run scope;不允许反向恢复更宽权限。 */
|
|
7
|
+
export function authorizationScopeContains(
|
|
8
|
+
current: AuthorizationScopeSnapshot,
|
|
9
|
+
frozen: AuthorizationScopeSnapshot,
|
|
10
|
+
): boolean {
|
|
11
|
+
return (
|
|
12
|
+
current.actorId === frozen.actorId &&
|
|
13
|
+
current.tenantId === frozen.tenantId &&
|
|
14
|
+
frozen.roleIds.every((roleId) => current.roleIds.includes(roleId)) &&
|
|
15
|
+
frozen.grants.every((required) =>
|
|
16
|
+
current.grants.some((grant) => grantContains(grant, required)),
|
|
17
|
+
)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function grantContains(
|
|
22
|
+
current: PersistedScopeGrant,
|
|
23
|
+
frozen: PersistedScopeGrant,
|
|
24
|
+
): boolean {
|
|
25
|
+
if (current.scope === "all" || current.scope === "tenant") return true;
|
|
26
|
+
if (current.scope === "self") return frozen.scope === "self";
|
|
27
|
+
if (current.scope === "custom") {
|
|
28
|
+
return (
|
|
29
|
+
frozen.scope === "custom" &&
|
|
30
|
+
current.dimension === frozen.dimension &&
|
|
31
|
+
frozen.values.every((value) => current.values.includes(value))
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (current.scope === "department-and-children") {
|
|
35
|
+
return (
|
|
36
|
+
(frozen.scope === "department" ||
|
|
37
|
+
frozen.scope === "department-and-children") &&
|
|
38
|
+
Boolean(current.departmentId) &&
|
|
39
|
+
current.departmentId === frozen.departmentId
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return (
|
|
43
|
+
current.scope === "department" &&
|
|
44
|
+
frozen.scope === "department" &&
|
|
45
|
+
Boolean(current.departmentId) &&
|
|
46
|
+
current.departmentId === frozen.departmentId
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
DurableHandlerRegistry,
|
|
4
|
+
type DurableArtifactPublicationReceipt,
|
|
5
|
+
} from "@southwind-ai/jobs";
|
|
6
|
+
import { artifactRef } from "@southwind-ai/storage";
|
|
7
|
+
import { DurableArtifactPublicationConflictError } from "./execution-repository.js";
|
|
8
|
+
import { InMemoryDurableExecutionRepository } from "./in-memory-execution.js";
|
|
9
|
+
import { DurableJobService } from "./service.js";
|
|
10
|
+
import { DurableJobWorker } from "./worker.js";
|
|
11
|
+
|
|
12
|
+
const base = new Date("2026-08-28T09:00:00.000Z");
|
|
13
|
+
const artifact = {
|
|
14
|
+
kind: "workflow-result",
|
|
15
|
+
artifactRef: artifactRef("blob_0123456789abcdef0123456789abcdef"),
|
|
16
|
+
filename: "result.json",
|
|
17
|
+
contentType: "application/json",
|
|
18
|
+
byteSize: 12,
|
|
19
|
+
sha256: "a".repeat(64),
|
|
20
|
+
ownerId: "user_1",
|
|
21
|
+
metadata: { nested: { second: 2, first: 1 } },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("durable artifact stable publication", () => {
|
|
25
|
+
test("repository 对相同发布返回首发事实并拒绝内容漂移", async () => {
|
|
26
|
+
const repository = new InMemoryDurableExecutionRepository();
|
|
27
|
+
const service = new DurableJobService(repository, () => base);
|
|
28
|
+
const submitted = await service.submit(jobInput("repository-once"));
|
|
29
|
+
const claim = await repository.claim(claimInput("worker-repository"));
|
|
30
|
+
if (!claim) throw new Error("任务未领取");
|
|
31
|
+
const publication = {
|
|
32
|
+
id: "dap_stable",
|
|
33
|
+
jobId: claim.jobId,
|
|
34
|
+
stageId: claim.stageId,
|
|
35
|
+
...artifact,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
await expect(
|
|
39
|
+
repository.publishArtifactOnce(
|
|
40
|
+
claim,
|
|
41
|
+
{ ...publication, jobId: "job_forged" },
|
|
42
|
+
base,
|
|
43
|
+
),
|
|
44
|
+
).rejects.toBeInstanceOf(DurableArtifactPublicationConflictError);
|
|
45
|
+
await expect(
|
|
46
|
+
repository.publishArtifactOnce(
|
|
47
|
+
claim,
|
|
48
|
+
{ ...publication, stageId: "stage_forged" },
|
|
49
|
+
base,
|
|
50
|
+
),
|
|
51
|
+
).rejects.toBeInstanceOf(DurableArtifactPublicationConflictError);
|
|
52
|
+
expect((await service.get(submitted.job.id))?.artifacts).toEqual([]);
|
|
53
|
+
|
|
54
|
+
const first = await repository.publishArtifactOnce(
|
|
55
|
+
claim,
|
|
56
|
+
publication,
|
|
57
|
+
base,
|
|
58
|
+
);
|
|
59
|
+
const replay = await repository.publishArtifactOnce(
|
|
60
|
+
claim,
|
|
61
|
+
{
|
|
62
|
+
...publication,
|
|
63
|
+
metadata: { nested: { first: 1, second: 2 } },
|
|
64
|
+
},
|
|
65
|
+
new Date(base.getTime() + 1),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(replay).toEqual(first);
|
|
69
|
+
await expect(
|
|
70
|
+
repository.publishArtifactOnce(
|
|
71
|
+
claim,
|
|
72
|
+
{ ...publication, filename: "drifted.json" },
|
|
73
|
+
new Date(base.getTime() + 2),
|
|
74
|
+
),
|
|
75
|
+
).rejects.toBeInstanceOf(DurableArtifactPublicationConflictError);
|
|
76
|
+
await expect(
|
|
77
|
+
repository.publishArtifactOnce(
|
|
78
|
+
claim,
|
|
79
|
+
{ ...publication, kind: "other-result" },
|
|
80
|
+
new Date(base.getTime() + 3),
|
|
81
|
+
),
|
|
82
|
+
).rejects.toBeInstanceOf(DurableArtifactPublicationConflictError);
|
|
83
|
+
expect((await service.get(submitted.job.id))?.artifacts).toEqual([
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
id: "dap_stable",
|
|
86
|
+
filename: "result.json",
|
|
87
|
+
createdAt: base.toISOString(),
|
|
88
|
+
}),
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("Worker 从稳定坐标生成同一 receipt", async () => {
|
|
93
|
+
const repository = new InMemoryDurableExecutionRepository();
|
|
94
|
+
const service = new DurableJobService(repository, () => base);
|
|
95
|
+
const submitted = await service.submit(jobInput("worker-once"));
|
|
96
|
+
const receipts: DurableArtifactPublicationReceipt[] = [];
|
|
97
|
+
const handlers = new DurableHandlerRegistry().register({
|
|
98
|
+
key: "test.handler",
|
|
99
|
+
payload: { parse: () => ({}) },
|
|
100
|
+
execute: async (_payload, context) => {
|
|
101
|
+
receipts.push(
|
|
102
|
+
await context.publishArtifactOnce!("page-agent-final", artifact),
|
|
103
|
+
);
|
|
104
|
+
receipts.push(
|
|
105
|
+
await context.publishArtifactOnce!("page-agent-final", artifact),
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const worker = new DurableJobWorker(repository, handlers, {
|
|
110
|
+
now: () => base,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(await worker.runNext("default", "worker-a")).toMatchObject({
|
|
114
|
+
status: "succeeded",
|
|
115
|
+
});
|
|
116
|
+
expect(receipts).toHaveLength(2);
|
|
117
|
+
expect(receipts[1]).toEqual(receipts[0]);
|
|
118
|
+
expect(receipts[0]?.artifactId).toMatch(/^dap_[a-f0-9]{60}$/);
|
|
119
|
+
expect((await service.get(submitted.job.id))?.artifacts).toEqual([
|
|
120
|
+
expect.objectContaining({ id: receipts[0]?.artifactId }),
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("取消请求阻止一次性产物写入", async () => {
|
|
125
|
+
const repository = new InMemoryDurableExecutionRepository();
|
|
126
|
+
const service = new DurableJobService(repository, () => base);
|
|
127
|
+
const submitted = await service.submit(jobInput("repository-cancelled"));
|
|
128
|
+
const claim = await repository.claim(claimInput("worker-cancelled"));
|
|
129
|
+
if (!claim) throw new Error("取消任务未领取");
|
|
130
|
+
await repository.requestCancel(claim.jobId, base);
|
|
131
|
+
|
|
132
|
+
expect(
|
|
133
|
+
await repository.publishArtifactOnce(
|
|
134
|
+
claim,
|
|
135
|
+
{
|
|
136
|
+
id: "dap_cancelled",
|
|
137
|
+
jobId: claim.jobId,
|
|
138
|
+
stageId: claim.stageId,
|
|
139
|
+
...artifact,
|
|
140
|
+
},
|
|
141
|
+
base,
|
|
142
|
+
),
|
|
143
|
+
).toMatchObject({ ok: true, cancelRequested: true });
|
|
144
|
+
expect((await service.get(submitted.job.id))?.artifacts).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("Worker 将同 publicationKey 的漂移固化为不可重试失败", async () => {
|
|
148
|
+
const repository = new InMemoryDurableExecutionRepository();
|
|
149
|
+
const service = new DurableJobService(repository, () => base);
|
|
150
|
+
const submitted = await service.submit(jobInput("worker-conflict"));
|
|
151
|
+
const handlers = new DurableHandlerRegistry().register({
|
|
152
|
+
key: "test.handler",
|
|
153
|
+
payload: { parse: () => ({}) },
|
|
154
|
+
execute: async (_payload, context) => {
|
|
155
|
+
await context.publishArtifactOnce!("page-agent-final", artifact);
|
|
156
|
+
await context.publishArtifactOnce!("page-agent-final", {
|
|
157
|
+
...artifact,
|
|
158
|
+
sha256: "b".repeat(64),
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
const worker = new DurableJobWorker(repository, handlers, {
|
|
163
|
+
now: () => base,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(await worker.runNext("default", "worker-a")).toMatchObject({
|
|
167
|
+
status: "failed",
|
|
168
|
+
});
|
|
169
|
+
expect(await service.get(submitted.job.id)).toMatchObject({
|
|
170
|
+
artifacts: [{ sha256: "a".repeat(64) }],
|
|
171
|
+
failures: [
|
|
172
|
+
{
|
|
173
|
+
code: "ARTIFACT_PUBLICATION_CONFLICT",
|
|
174
|
+
retryable: false,
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
function jobInput(idempotencyKey: string) {
|
|
182
|
+
return {
|
|
183
|
+
handlerKey: "test.handler",
|
|
184
|
+
idempotencyKey,
|
|
185
|
+
payload: {},
|
|
186
|
+
requestedBy: "user_1",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function claimInput(workerId: string) {
|
|
191
|
+
return { queue: "default", workerId, now: base, leaseDurationMs: 30_000 };
|
|
192
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DurableJobArtifact,
|
|
3
|
+
DurableLeaseIdentity,
|
|
4
|
+
} from "@southwind-ai/jobs";
|
|
5
|
+
import {
|
|
6
|
+
DurableArtifactPublicationConflictError,
|
|
7
|
+
type DurableArtifactPublication,
|
|
8
|
+
} from "./execution-repository.js";
|
|
9
|
+
|
|
10
|
+
export function assertArtifactPublicationIdentity(
|
|
11
|
+
identity: DurableLeaseIdentity,
|
|
12
|
+
candidate: DurableArtifactPublication,
|
|
13
|
+
) {
|
|
14
|
+
if (
|
|
15
|
+
candidate.jobId !== identity.jobId ||
|
|
16
|
+
candidate.stageId !== identity.stageId
|
|
17
|
+
) {
|
|
18
|
+
throw new DurableArtifactPublicationConflictError(
|
|
19
|
+
"产物发布身份与任务租约不匹配",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sameArtifactPublication(
|
|
25
|
+
persisted: DurableJobArtifact,
|
|
26
|
+
candidate: DurableArtifactPublication,
|
|
27
|
+
): boolean {
|
|
28
|
+
return (
|
|
29
|
+
persisted.id === candidate.id &&
|
|
30
|
+
persisted.jobId === candidate.jobId &&
|
|
31
|
+
persisted.stageId === candidate.stageId &&
|
|
32
|
+
persisted.kind === candidate.kind &&
|
|
33
|
+
persisted.artifactRef === candidate.artifactRef &&
|
|
34
|
+
persisted.filename === candidate.filename &&
|
|
35
|
+
persisted.contentType === candidate.contentType &&
|
|
36
|
+
persisted.byteSize === candidate.byteSize &&
|
|
37
|
+
persisted.sha256 === candidate.sha256 &&
|
|
38
|
+
persisted.ownerId === candidate.ownerId &&
|
|
39
|
+
persisted.expiresAt === candidate.expiresAt &&
|
|
40
|
+
canonicalJson(persisted.metadata) === canonicalJson(candidate.metadata)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function canonicalJson(value: unknown): string | undefined {
|
|
45
|
+
return JSON.stringify(canonicalValue(value));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function canonicalValue(value: unknown): unknown {
|
|
49
|
+
if (Array.isArray(value)) return value.map(canonicalValue);
|
|
50
|
+
if (!value || typeof value !== "object") return value;
|
|
51
|
+
return Object.fromEntries(
|
|
52
|
+
Object.entries(value)
|
|
53
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
54
|
+
.map(([key, entry]) => [key, canonicalValue(entry)]),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DurableHandlerError,
|
|
3
|
+
type DurableHandlerContext,
|
|
4
|
+
type DurableLeaseIdentity,
|
|
5
|
+
} from "@southwind-ai/jobs";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import {
|
|
8
|
+
DurableArtifactPublicationConflictError,
|
|
9
|
+
type DurableExecutionRepository,
|
|
10
|
+
type LeaseResult,
|
|
11
|
+
} from "./execution-repository.js";
|
|
12
|
+
|
|
13
|
+
export function createDurableArtifactPublisher(input: {
|
|
14
|
+
readonly repository: DurableExecutionRepository;
|
|
15
|
+
readonly identity: DurableLeaseIdentity;
|
|
16
|
+
readonly stageKey: string;
|
|
17
|
+
readonly now: () => Date;
|
|
18
|
+
readonly assertLease: (result: LeaseResult) => void;
|
|
19
|
+
}): Required<
|
|
20
|
+
Pick<DurableHandlerContext, "publishArtifact" | "publishArtifactOnce">
|
|
21
|
+
> {
|
|
22
|
+
return {
|
|
23
|
+
publishArtifact: async (artifact) => {
|
|
24
|
+
const result = await input.repository.publishArtifact(
|
|
25
|
+
input.identity,
|
|
26
|
+
{
|
|
27
|
+
id: Bun.randomUUIDv7(),
|
|
28
|
+
jobId: input.identity.jobId,
|
|
29
|
+
stageId: input.identity.stageId,
|
|
30
|
+
...artifact,
|
|
31
|
+
createdAt: input.now().toISOString(),
|
|
32
|
+
},
|
|
33
|
+
input.now(),
|
|
34
|
+
);
|
|
35
|
+
input.assertLease(result);
|
|
36
|
+
},
|
|
37
|
+
publishArtifactOnce: async (publicationKey, artifact) => {
|
|
38
|
+
validatePublicationKey(publicationKey);
|
|
39
|
+
let result;
|
|
40
|
+
try {
|
|
41
|
+
result = await input.repository.publishArtifactOnce(
|
|
42
|
+
input.identity,
|
|
43
|
+
{
|
|
44
|
+
id: stableArtifactId(input.identity, publicationKey),
|
|
45
|
+
jobId: input.identity.jobId,
|
|
46
|
+
stageId: input.identity.stageId,
|
|
47
|
+
...artifact,
|
|
48
|
+
},
|
|
49
|
+
input.now(),
|
|
50
|
+
);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error instanceof DurableArtifactPublicationConflictError) {
|
|
53
|
+
throw new DurableHandlerError(
|
|
54
|
+
"ARTIFACT_PUBLICATION_CONFLICT",
|
|
55
|
+
error.message,
|
|
56
|
+
false,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
input.assertLease(result);
|
|
63
|
+
throw new Error("产物发布租约校验未终结");
|
|
64
|
+
}
|
|
65
|
+
input.assertLease(result);
|
|
66
|
+
if (result.cancelRequested) {
|
|
67
|
+
throw new Error("已取消任务不应返回产物发布事实");
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
schemaVersion: 1,
|
|
71
|
+
publicationKey,
|
|
72
|
+
artifactId: result.artifact.id,
|
|
73
|
+
jobId: input.identity.jobId,
|
|
74
|
+
stageKey: input.stageKey,
|
|
75
|
+
kind: result.artifact.kind,
|
|
76
|
+
artifactRef: result.artifact.artifactRef,
|
|
77
|
+
sha256: result.artifact.sha256,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validatePublicationKey(value: string) {
|
|
84
|
+
if (!value || value !== value.trim() || value.length > 160) {
|
|
85
|
+
throw new DurableHandlerError(
|
|
86
|
+
"ARTIFACT_PUBLICATION_KEY_INVALID",
|
|
87
|
+
"产物 publicationKey 必须为 1 到 160 个非空白字符",
|
|
88
|
+
false,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function stableArtifactId(
|
|
94
|
+
identity: Pick<DurableLeaseIdentity, "jobId" | "stageId">,
|
|
95
|
+
publicationKey: string,
|
|
96
|
+
) {
|
|
97
|
+
const digest = createHash("sha256")
|
|
98
|
+
.update(JSON.stringify([identity.jobId, identity.stageId, publicationKey]))
|
|
99
|
+
.digest("hex");
|
|
100
|
+
return `dap_${digest.slice(0, 60)}`;
|
|
101
|
+
}
|
|
@@ -4,13 +4,21 @@ import {
|
|
|
4
4
|
durableJobs,
|
|
5
5
|
} from "@southwind-ai/database";
|
|
6
6
|
import type { DurableLeaseIdentity } from "@southwind-ai/jobs";
|
|
7
|
-
import { eq } from "drizzle-orm";
|
|
7
|
+
import { and, eq, or } from "drizzle-orm";
|
|
8
8
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
9
9
|
import type {
|
|
10
|
+
DurableArtifactPublication,
|
|
11
|
+
DurableArtifactPublicationResult,
|
|
10
12
|
DurableExecutionRepository,
|
|
11
13
|
LeaseResult,
|
|
12
14
|
} from "./execution-repository.js";
|
|
15
|
+
import { DurableArtifactPublicationConflictError } from "./execution-repository.js";
|
|
13
16
|
import { activeLeaseWhere } from "./drizzle-lease.js";
|
|
17
|
+
import {
|
|
18
|
+
assertArtifactPublicationIdentity,
|
|
19
|
+
sameArtifactPublication,
|
|
20
|
+
} from "./artifact-publication.js";
|
|
21
|
+
import { mapArtifact } from "./drizzle-mappers.js";
|
|
14
22
|
|
|
15
23
|
type Artifact = Parameters<DurableExecutionRepository["publishArtifact"]>[1];
|
|
16
24
|
|
|
@@ -53,6 +61,70 @@ export function publishDrizzleArtifact(
|
|
|
53
61
|
});
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
export function publishDrizzleArtifactOnce(
|
|
65
|
+
db: NodePgDatabase,
|
|
66
|
+
identity: DurableLeaseIdentity,
|
|
67
|
+
artifact: DurableArtifactPublication,
|
|
68
|
+
now: Date,
|
|
69
|
+
leaseFailure: () => Promise<LeaseResult>,
|
|
70
|
+
): Promise<DurableArtifactPublicationResult> {
|
|
71
|
+
return db.transaction(async (tx) => {
|
|
72
|
+
const [lease] = await tx
|
|
73
|
+
.select()
|
|
74
|
+
.from(durableJobLeases)
|
|
75
|
+
.where(activeLeaseWhere(identity, now))
|
|
76
|
+
.for("update")
|
|
77
|
+
.limit(1);
|
|
78
|
+
if (!lease) {
|
|
79
|
+
const failure = await leaseFailure();
|
|
80
|
+
if (failure.ok) {
|
|
81
|
+
throw new Error("DURABLE_ARTIFACT_LEASE_FAILURE_INVALID");
|
|
82
|
+
}
|
|
83
|
+
return failure;
|
|
84
|
+
}
|
|
85
|
+
const [job] = await tx
|
|
86
|
+
.select({ cancelRequestedAt: durableJobs.cancelRequestedAt })
|
|
87
|
+
.from(durableJobs)
|
|
88
|
+
.where(eq(durableJobs.id, identity.jobId))
|
|
89
|
+
.for("update")
|
|
90
|
+
.limit(1);
|
|
91
|
+
if (!job) return { ok: false, reason: "not-found" } as const;
|
|
92
|
+
assertArtifactPublicationIdentity(identity, artifact);
|
|
93
|
+
const expiresAt = lease.expiresAt.toISOString();
|
|
94
|
+
if (job.cancelRequestedAt) {
|
|
95
|
+
return { ok: true, cancelRequested: true, expiresAt } as const;
|
|
96
|
+
}
|
|
97
|
+
const leaseResult = {
|
|
98
|
+
ok: true,
|
|
99
|
+
cancelRequested: false,
|
|
100
|
+
expiresAt,
|
|
101
|
+
} as const;
|
|
102
|
+
const candidate = { ...artifact, createdAt: now.toISOString() };
|
|
103
|
+
const [row] = await tx
|
|
104
|
+
.select()
|
|
105
|
+
.from(durableJobArtifacts)
|
|
106
|
+
.where(
|
|
107
|
+
or(
|
|
108
|
+
eq(durableJobArtifacts.id, artifact.id),
|
|
109
|
+
and(
|
|
110
|
+
eq(durableJobArtifacts.jobId, artifact.jobId),
|
|
111
|
+
eq(durableJobArtifacts.kind, artifact.kind),
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
.limit(1);
|
|
116
|
+
if (row) {
|
|
117
|
+
const existing = mapArtifact(row);
|
|
118
|
+
if (!sameArtifactPublication(existing, artifact)) {
|
|
119
|
+
throw new DurableArtifactPublicationConflictError();
|
|
120
|
+
}
|
|
121
|
+
return { ...leaseResult, artifact: existing };
|
|
122
|
+
}
|
|
123
|
+
await tx.insert(durableJobArtifacts).values(toDatabaseArtifact(candidate));
|
|
124
|
+
return { ...leaseResult, artifact: candidate };
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
56
128
|
function toDatabaseArtifact(artifact: Artifact) {
|
|
57
129
|
return {
|
|
58
130
|
...artifact,
|
|
@@ -20,7 +20,10 @@ import type {
|
|
|
20
20
|
} from "./execution-repository.js";
|
|
21
21
|
import { DrizzleDurableRecoveryRepository } from "./drizzle-recovery-repository.js";
|
|
22
22
|
import { claimDurableJob } from "./drizzle-claim.js";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
publishDrizzleArtifact,
|
|
25
|
+
publishDrizzleArtifactOnce,
|
|
26
|
+
} from "./drizzle-artifact.js";
|
|
24
27
|
import { activeLeaseWhere } from "./drizzle-lease.js";
|
|
25
28
|
|
|
26
29
|
export class DrizzleDurableExecutionRepository implements DurableExecutionRepository {
|
|
@@ -95,6 +98,16 @@ export class DrizzleDurableExecutionRepository implements DurableExecutionReposi
|
|
|
95
98
|
);
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
async publishArtifactOnce(
|
|
102
|
+
identity: DurableLeaseIdentity,
|
|
103
|
+
artifact: Parameters<DurableExecutionRepository["publishArtifactOnce"]>[1],
|
|
104
|
+
now: Date,
|
|
105
|
+
) {
|
|
106
|
+
return publishDrizzleArtifactOnce(this.db, identity, artifact, now, () =>
|
|
107
|
+
this.leaseFailure(identity, now),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
98
111
|
async complete(identity: DurableLeaseIdentity, now: Date) {
|
|
99
112
|
return this.db.transaction(async (tx) => {
|
|
100
113
|
const [lease] = await tx
|