create-windy 0.6.14 → 0.6.15
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 +215 -24
- 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/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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLIENT_AGENT_WORKFLOW_ARTIFACT_TASK_KEY,
|
|
3
|
+
type ModuleClientAgentWorkflowArtifactProjectBinding,
|
|
4
|
+
} from "@southwind-ai/server-sdk";
|
|
5
|
+
import type {
|
|
6
|
+
AgentWorkflowDefinition,
|
|
7
|
+
AgentWorkflowNode,
|
|
8
|
+
AgentWorkflowRelease,
|
|
9
|
+
} from "@southwind-ai/shared";
|
|
10
|
+
|
|
11
|
+
/** PageAgent 权限只属于显式审核的 Immutable Release 与固定三节点拓扑。 */
|
|
12
|
+
export function assertClientAgentWorkflowReleaseBinding(
|
|
13
|
+
release: AgentWorkflowRelease,
|
|
14
|
+
binding: ModuleClientAgentWorkflowArtifactProjectBinding,
|
|
15
|
+
) {
|
|
16
|
+
const definition = release.definition;
|
|
17
|
+
const pageAgentJobs = definition.nodes.filter(
|
|
18
|
+
(node) =>
|
|
19
|
+
node.type === "job" &&
|
|
20
|
+
node.taskKey === CLIENT_AGENT_WORKFLOW_ARTIFACT_TASK_KEY,
|
|
21
|
+
);
|
|
22
|
+
const job = pageAgentJobs[0];
|
|
23
|
+
if (
|
|
24
|
+
release.releaseDigest !== binding.releaseDigest ||
|
|
25
|
+
definition.workflowKey !== binding.workflowKey ||
|
|
26
|
+
pageAgentJobs.length !== 1 ||
|
|
27
|
+
!job ||
|
|
28
|
+
job.id !== binding.nodeId ||
|
|
29
|
+
!failClosed(job)
|
|
30
|
+
) {
|
|
31
|
+
invalid();
|
|
32
|
+
}
|
|
33
|
+
const artifact = uniqueSuccessor(definition, job, "artifact");
|
|
34
|
+
if (
|
|
35
|
+
artifact.artifactKind !== binding.artifact.kind ||
|
|
36
|
+
artifact.policyKey !== binding.artifact.retentionPolicyKey ||
|
|
37
|
+
!failClosed(artifact)
|
|
38
|
+
) {
|
|
39
|
+
invalid();
|
|
40
|
+
}
|
|
41
|
+
const final = uniqueSuccessor(definition, artifact, "final");
|
|
42
|
+
if (!failClosed(final) || outgoing(definition, final.id).length) invalid();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function uniqueSuccessor<T extends "artifact" | "final">(
|
|
46
|
+
definition: AgentWorkflowDefinition,
|
|
47
|
+
source: AgentWorkflowNode,
|
|
48
|
+
type: T,
|
|
49
|
+
): Extract<AgentWorkflowNode, { type: T }> {
|
|
50
|
+
const edges = outgoing(definition, source.id);
|
|
51
|
+
const edge = edges[0];
|
|
52
|
+
const node = definition.nodes.find(({ id }) => id === edge?.to);
|
|
53
|
+
const incoming = node
|
|
54
|
+
? definition.edges.filter(({ to }) => to === node.id)
|
|
55
|
+
: [];
|
|
56
|
+
if (
|
|
57
|
+
edges.length !== 1 ||
|
|
58
|
+
!edge ||
|
|
59
|
+
edge.branch !== undefined ||
|
|
60
|
+
!node ||
|
|
61
|
+
node.type !== type ||
|
|
62
|
+
incoming.length !== 1 ||
|
|
63
|
+
incoming[0]?.from !== source.id ||
|
|
64
|
+
incoming[0]?.branch !== undefined
|
|
65
|
+
) {
|
|
66
|
+
invalid();
|
|
67
|
+
}
|
|
68
|
+
return node as Extract<AgentWorkflowNode, { type: T }>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function outgoing(definition: AgentWorkflowDefinition, nodeId: string) {
|
|
72
|
+
return definition.edges.filter(({ from }) => from === nodeId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function failClosed(node: AgentWorkflowNode) {
|
|
76
|
+
return (
|
|
77
|
+
node.failurePolicy.mode === "fail-run" ||
|
|
78
|
+
(node.failurePolicy.mode === "retry" &&
|
|
79
|
+
node.failurePolicy.exhausted === "fail-run")
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function invalid(): never {
|
|
84
|
+
throw new Error("CLIENT_AGENT_WORKFLOW_RELEASE_BINDING_INVALID");
|
|
85
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ArtifactRef } from "@southwind-ai/storage";
|
|
3
|
+
import { InMemoryClientAgentWorkflowArtifactRepository } from "./in-memory-repository.js";
|
|
4
|
+
import type { ClientAgentWorkflowArtifactRecord } from "./contracts.js";
|
|
5
|
+
|
|
6
|
+
describe("Client Agent Workflow Artifact lifecycle", () => {
|
|
7
|
+
test("pending 先持久化,claim generation 使旧领取失效", async () => {
|
|
8
|
+
const repository = new InMemoryClientAgentWorkflowArtifactRepository();
|
|
9
|
+
const pending = await repository.begin(record());
|
|
10
|
+
expect(pending.status).toBe("pending");
|
|
11
|
+
const first = await repository.claim({
|
|
12
|
+
workId: pending.workId,
|
|
13
|
+
expectedVersion: pending.version,
|
|
14
|
+
binding: claim("sha256:first", 200),
|
|
15
|
+
});
|
|
16
|
+
expect(first.outcome).toBe("claimed");
|
|
17
|
+
if (first.outcome !== "claimed") throw new Error("claim failed");
|
|
18
|
+
const busy = await repository.claim({
|
|
19
|
+
workId: pending.workId,
|
|
20
|
+
expectedVersion: first.record.version,
|
|
21
|
+
binding: claim("sha256:busy", 199),
|
|
22
|
+
});
|
|
23
|
+
expect(busy.outcome).toBe("busy");
|
|
24
|
+
const expiredWithoutFence = await repository.claim({
|
|
25
|
+
workId: pending.workId,
|
|
26
|
+
expectedVersion: first.record.version,
|
|
27
|
+
binding: claim("sha256:second", 201),
|
|
28
|
+
});
|
|
29
|
+
expect(expiredWithoutFence.outcome).toBe("busy");
|
|
30
|
+
const takeover = await repository.claim({
|
|
31
|
+
workId: pending.workId,
|
|
32
|
+
expectedVersion: first.record.version,
|
|
33
|
+
allowActiveTakeover: true,
|
|
34
|
+
binding: claim("sha256:second", 201),
|
|
35
|
+
});
|
|
36
|
+
expect(takeover.outcome).toBe("claimed");
|
|
37
|
+
if (takeover.outcome !== "claimed") throw new Error("takeover failed");
|
|
38
|
+
expect(takeover.record.claimGeneration).toBe(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("completed 与 cancelled 为吸收态,迟到写 fail closed", async () => {
|
|
42
|
+
const repository = new InMemoryClientAgentWorkflowArtifactRepository();
|
|
43
|
+
const pending = await repository.begin(record());
|
|
44
|
+
const claimed = await repository.claim({
|
|
45
|
+
workId: pending.workId,
|
|
46
|
+
expectedVersion: pending.version,
|
|
47
|
+
binding: claim("sha256:claim", 200),
|
|
48
|
+
});
|
|
49
|
+
if (claimed.outcome !== "claimed") throw new Error("claim failed");
|
|
50
|
+
const bound = await repository.bindClientSession({
|
|
51
|
+
workId: pending.workId,
|
|
52
|
+
claimGeneration: 1,
|
|
53
|
+
clientSessionId: "cas_1",
|
|
54
|
+
now: 101,
|
|
55
|
+
});
|
|
56
|
+
expect(bound?.clientSessionId).toBe("cas_1");
|
|
57
|
+
const completed = await repository.complete({
|
|
58
|
+
workId: pending.workId,
|
|
59
|
+
claimTokenHash: "sha256:claim",
|
|
60
|
+
terminal: {
|
|
61
|
+
claimGeneration: 1,
|
|
62
|
+
clientSessionId: "cas_1",
|
|
63
|
+
historySequence: 1,
|
|
64
|
+
historyHeadDigest: "sha256:history",
|
|
65
|
+
terminalReceiptId: "cawr_terminal",
|
|
66
|
+
terminalReceiptDigest: "sha256:terminal",
|
|
67
|
+
terminalRequestDigest: "sha256:request-terminal",
|
|
68
|
+
terminalResult: { result: "ok" },
|
|
69
|
+
artifact: {
|
|
70
|
+
artifactRef: "blob_11111111111111111111111111111111" as ArtifactRef,
|
|
71
|
+
filename: "result.json",
|
|
72
|
+
contentType: "application/json",
|
|
73
|
+
byteSize: 2,
|
|
74
|
+
sha256: "1".repeat(64),
|
|
75
|
+
},
|
|
76
|
+
now: 102,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
expect(completed?.status).toBe("completed");
|
|
80
|
+
expect(
|
|
81
|
+
await repository.requestCancel({ workId: pending.workId, now: 103 }),
|
|
82
|
+
).toBe(undefined);
|
|
83
|
+
expect(
|
|
84
|
+
await repository.complete({
|
|
85
|
+
workId: pending.workId,
|
|
86
|
+
claimTokenHash: "sha256:stale",
|
|
87
|
+
terminal: {
|
|
88
|
+
claimGeneration: 1,
|
|
89
|
+
clientSessionId: "cas_1",
|
|
90
|
+
historySequence: 1,
|
|
91
|
+
historyHeadDigest: "sha256:stale",
|
|
92
|
+
terminalReceiptId: "cawr_stale",
|
|
93
|
+
terminalReceiptDigest: "sha256:stale",
|
|
94
|
+
terminalRequestDigest: "sha256:stale-request",
|
|
95
|
+
terminalResult: { result: "late" },
|
|
96
|
+
artifact: completed!.artifact!,
|
|
97
|
+
now: 104,
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
).toBe(undefined);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("取消先持久 intent,再进入吸收终态", async () => {
|
|
104
|
+
const repository = new InMemoryClientAgentWorkflowArtifactRepository();
|
|
105
|
+
const pending = await repository.begin(record());
|
|
106
|
+
const requested = await repository.requestCancel({
|
|
107
|
+
workId: pending.workId,
|
|
108
|
+
now: 101,
|
|
109
|
+
});
|
|
110
|
+
expect(requested?.status).toBe("cancellation-requested");
|
|
111
|
+
const cancelled = await repository.finalizeCancel({
|
|
112
|
+
workId: pending.workId,
|
|
113
|
+
now: 102,
|
|
114
|
+
});
|
|
115
|
+
expect(cancelled?.status).toBe("cancelled");
|
|
116
|
+
expect(
|
|
117
|
+
await repository.finalizeCancel({ workId: pending.workId, now: 103 }),
|
|
118
|
+
).toEqual(cancelled);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("attempt authority 绑定 Session、Actor、Operation、client 与 Host digest", async () => {
|
|
122
|
+
const repository = new InMemoryClientAgentWorkflowArtifactRepository();
|
|
123
|
+
const pending = await repository.begin(record());
|
|
124
|
+
const claimed = await repository.claim({
|
|
125
|
+
workId: pending.workId,
|
|
126
|
+
expectedVersion: pending.version,
|
|
127
|
+
binding: claim("sha256:claim", 101),
|
|
128
|
+
});
|
|
129
|
+
if (claimed.outcome !== "claimed") throw new Error("claim failed");
|
|
130
|
+
await repository.bindClientSession({
|
|
131
|
+
workId: pending.workId,
|
|
132
|
+
claimGeneration: claimed.record.claimGeneration,
|
|
133
|
+
clientSessionId: "cas_authority",
|
|
134
|
+
now: 102,
|
|
135
|
+
});
|
|
136
|
+
const facts = {
|
|
137
|
+
clientSessionId: "cas_authority",
|
|
138
|
+
actorId: "actor-1",
|
|
139
|
+
operationKey: "analysis.page-agent",
|
|
140
|
+
clientBindingDigest: "sha256:binding" as const,
|
|
141
|
+
hostSessionDigest: "sha256:host" as const,
|
|
142
|
+
now: 103,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
await expect(repository.authorizeClientSessionAttempt(facts)).resolves.toBe(
|
|
146
|
+
"current",
|
|
147
|
+
);
|
|
148
|
+
await expect(
|
|
149
|
+
repository.authorizeClientSessionAttempt({
|
|
150
|
+
...facts,
|
|
151
|
+
actorId: "actor-drift",
|
|
152
|
+
}),
|
|
153
|
+
).resolves.toBe("revoked");
|
|
154
|
+
await expect(
|
|
155
|
+
repository.authorizeClientSessionAttempt({
|
|
156
|
+
...facts,
|
|
157
|
+
clientSessionId: "cas_ordinary",
|
|
158
|
+
}),
|
|
159
|
+
).resolves.toBe("ordinary");
|
|
160
|
+
await repository.requestCancel({ workId: pending.workId, now: 104 });
|
|
161
|
+
await expect(repository.authorizeClientSessionAttempt(facts)).resolves.toBe(
|
|
162
|
+
"revoked",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
function record(): ClientAgentWorkflowArtifactRecord {
|
|
168
|
+
return {
|
|
169
|
+
workId: "cawa_work",
|
|
170
|
+
commandId: "command-1",
|
|
171
|
+
durableJobId: "job-1",
|
|
172
|
+
runId: "run-1",
|
|
173
|
+
nodeRunId: "node-run-1",
|
|
174
|
+
effectKind: "job",
|
|
175
|
+
taskKey: "system.agent-workflow.client-agent-artifact",
|
|
176
|
+
operationKey: "analysis.page-agent",
|
|
177
|
+
bindingDigest: "sha256:binding-policy",
|
|
178
|
+
actorId: "actor-1",
|
|
179
|
+
scopeDigest: "sha256:scope",
|
|
180
|
+
authorizationSnapshotDigest: "sha256:authorization",
|
|
181
|
+
runPolicyDigest: "sha256:policy",
|
|
182
|
+
requestDigest: "sha256:request",
|
|
183
|
+
inputDigest: "sha256:input",
|
|
184
|
+
effectGeneration: 1,
|
|
185
|
+
effectFenceTokenDigest: "sha256:fence",
|
|
186
|
+
status: "pending",
|
|
187
|
+
claimGeneration: 0,
|
|
188
|
+
historySequence: 0,
|
|
189
|
+
version: 1,
|
|
190
|
+
createdAt: 100,
|
|
191
|
+
updatedAt: 100,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function claim(token: `sha256:${string}`, now: number) {
|
|
196
|
+
return {
|
|
197
|
+
clientBindingDigest: "sha256:binding" as const,
|
|
198
|
+
installationId: "installation-1",
|
|
199
|
+
hostSessionDigest: "sha256:host" as const,
|
|
200
|
+
claimTokenHash: token,
|
|
201
|
+
claimExpiresAt: 200,
|
|
202
|
+
now,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClientAgentWorkflowArtifactRecord,
|
|
3
|
+
ClientAgentWorkflowClaimBinding,
|
|
4
|
+
ClientAgentWorkflowTerminalInput,
|
|
5
|
+
} from "./contracts.js";
|
|
6
|
+
import type { ArtifactRef } from "@southwind-ai/storage";
|
|
7
|
+
|
|
8
|
+
export type ClientAgentWorkflowClaimResult =
|
|
9
|
+
| {
|
|
10
|
+
readonly outcome: "claimed";
|
|
11
|
+
readonly record: ClientAgentWorkflowArtifactRecord;
|
|
12
|
+
readonly previousClientSessionId?: string;
|
|
13
|
+
}
|
|
14
|
+
| {
|
|
15
|
+
readonly outcome: "busy" | "completed" | "cancelled" | "conflict";
|
|
16
|
+
readonly record?: ClientAgentWorkflowArtifactRecord;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface ClientAgentWorkflowArtifactRepository {
|
|
20
|
+
begin(
|
|
21
|
+
record: ClientAgentWorkflowArtifactRecord,
|
|
22
|
+
): Promise<ClientAgentWorkflowArtifactRecord>;
|
|
23
|
+
find(workId: string): Promise<ClientAgentWorkflowArtifactRecord | undefined>;
|
|
24
|
+
findByJob(
|
|
25
|
+
durableJobId: string,
|
|
26
|
+
): Promise<ClientAgentWorkflowArtifactRecord | undefined>;
|
|
27
|
+
authorizeClientSessionAttempt(input: {
|
|
28
|
+
clientSessionId: string;
|
|
29
|
+
actorId: string;
|
|
30
|
+
operationKey: string;
|
|
31
|
+
clientBindingDigest: `sha256:${string}`;
|
|
32
|
+
hostSessionDigest: `sha256:${string}`;
|
|
33
|
+
now: number;
|
|
34
|
+
}): Promise<"ordinary" | "current" | "revoked">;
|
|
35
|
+
findForActorRun(input: {
|
|
36
|
+
actorId: string;
|
|
37
|
+
runId: string;
|
|
38
|
+
}): Promise<ClientAgentWorkflowArtifactRecord | undefined>;
|
|
39
|
+
claim(input: {
|
|
40
|
+
workId: string;
|
|
41
|
+
expectedVersion: number;
|
|
42
|
+
allowActiveTakeover?: boolean;
|
|
43
|
+
binding: ClientAgentWorkflowClaimBinding;
|
|
44
|
+
}): Promise<ClientAgentWorkflowClaimResult>;
|
|
45
|
+
bindClientSession(input: {
|
|
46
|
+
workId: string;
|
|
47
|
+
claimGeneration: number;
|
|
48
|
+
clientSessionId: string;
|
|
49
|
+
now: number;
|
|
50
|
+
}): Promise<ClientAgentWorkflowArtifactRecord | undefined>;
|
|
51
|
+
complete(input: {
|
|
52
|
+
workId: string;
|
|
53
|
+
claimTokenHash: `sha256:${string}`;
|
|
54
|
+
terminal: ClientAgentWorkflowTerminalInput;
|
|
55
|
+
}): Promise<ClientAgentWorkflowArtifactRecord | undefined>;
|
|
56
|
+
requestCancel(input: {
|
|
57
|
+
workId: string;
|
|
58
|
+
now: number;
|
|
59
|
+
}): Promise<ClientAgentWorkflowArtifactRecord | undefined>;
|
|
60
|
+
finalizeCancel(input: {
|
|
61
|
+
workId: string;
|
|
62
|
+
now: number;
|
|
63
|
+
}): Promise<ClientAgentWorkflowArtifactRecord | undefined>;
|
|
64
|
+
listClaimedByHost(input: {
|
|
65
|
+
actorId: string;
|
|
66
|
+
hostSessionDigest: `sha256:${string}`;
|
|
67
|
+
}): Promise<readonly ClientAgentWorkflowArtifactRecord[]>;
|
|
68
|
+
listCancellationRequested(): Promise<
|
|
69
|
+
readonly ClientAgentWorkflowArtifactRecord[]
|
|
70
|
+
>;
|
|
71
|
+
listActiveArtifactRefs(now: Date): Promise<ArtifactRef[]>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function sameWorkflowArtifactIdentity(
|
|
75
|
+
left: ClientAgentWorkflowArtifactRecord,
|
|
76
|
+
right: ClientAgentWorkflowArtifactRecord,
|
|
77
|
+
) {
|
|
78
|
+
return (
|
|
79
|
+
left.workId === right.workId &&
|
|
80
|
+
left.commandId === right.commandId &&
|
|
81
|
+
left.durableJobId === right.durableJobId &&
|
|
82
|
+
left.runId === right.runId &&
|
|
83
|
+
left.nodeRunId === right.nodeRunId &&
|
|
84
|
+
left.effectKind === right.effectKind &&
|
|
85
|
+
left.taskKey === right.taskKey &&
|
|
86
|
+
left.operationKey === right.operationKey &&
|
|
87
|
+
left.bindingDigest === right.bindingDigest &&
|
|
88
|
+
left.actorId === right.actorId &&
|
|
89
|
+
left.scopeDigest === right.scopeDigest &&
|
|
90
|
+
left.authorizationSnapshotDigest === right.authorizationSnapshotDigest &&
|
|
91
|
+
left.runPolicyDigest === right.runPolicyDigest &&
|
|
92
|
+
left.requestDigest === right.requestDigest &&
|
|
93
|
+
left.inputDigest === right.inputDigest &&
|
|
94
|
+
left.effectGeneration === right.effectGeneration &&
|
|
95
|
+
left.effectFenceTokenDigest === right.effectFenceTokenDigest
|
|
96
|
+
);
|
|
97
|
+
}
|
package/template/apps/server/src/agent/client-workflow-artifact/routes-handler.contract.test.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
DurableHandlerRegistry,
|
|
4
|
+
type DurableHandlerContext,
|
|
5
|
+
} from "@southwind-ai/jobs";
|
|
6
|
+
import { artifactRef } from "@southwind-ai/storage";
|
|
7
|
+
import type { RouteApp } from "../../system/routes.js";
|
|
8
|
+
import { hostSessionDigest } from "../client-session/host-auth.js";
|
|
9
|
+
import { registerClientAgentWorkflowArtifactHandler } from "./handler.js";
|
|
10
|
+
import { registerClientAgentWorkflowArtifactRoutes } from "./routes.js";
|
|
11
|
+
import type { GovernedClientAgentWorkflowArtifactService } from "./service.js";
|
|
12
|
+
import {
|
|
13
|
+
clientBindingDigest,
|
|
14
|
+
guardContext,
|
|
15
|
+
principal,
|
|
16
|
+
workflowBinding,
|
|
17
|
+
workflowInput,
|
|
18
|
+
workflowRecord,
|
|
19
|
+
} from "./workflow-artifact.test-support.js";
|
|
20
|
+
|
|
21
|
+
describe("Governed Client Agent Workflow Artifact routes", () => {
|
|
22
|
+
test("Route 拒绝 Provider 坐标注入,且只把 canonical Host principal 交给 Service", async () => {
|
|
23
|
+
const calls: unknown[] = [];
|
|
24
|
+
const service = {
|
|
25
|
+
claim: async (input: unknown) => {
|
|
26
|
+
calls.push(input);
|
|
27
|
+
return { workId: "cawa_work-1", status: "running", claimVersion: 1 };
|
|
28
|
+
},
|
|
29
|
+
} as unknown as GovernedClientAgentWorkflowArtifactService;
|
|
30
|
+
const app = captureRoutes();
|
|
31
|
+
registerClientAgentWorkflowArtifactRoutes(app, service);
|
|
32
|
+
const handler = app.handlers.get("/agent/client-workflow-artifacts/claim")!;
|
|
33
|
+
const context = {
|
|
34
|
+
request: new Request("https://windy.example/claim", { method: "POST" }),
|
|
35
|
+
guardContext,
|
|
36
|
+
clientAgentHost: {
|
|
37
|
+
principal,
|
|
38
|
+
hostSessionDigest: hostSessionDigest(principal),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const rejected = await handler({
|
|
43
|
+
...context,
|
|
44
|
+
body: {
|
|
45
|
+
workflowRunId: "run-1",
|
|
46
|
+
clientBindingDigest,
|
|
47
|
+
providerUrl: "https://attacker.example/v1",
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
expect(rejected).toBeInstanceOf(Response);
|
|
51
|
+
expect((rejected as Response).status).toBe(400);
|
|
52
|
+
expect(calls).toHaveLength(0);
|
|
53
|
+
|
|
54
|
+
await expect(
|
|
55
|
+
handler({
|
|
56
|
+
...context,
|
|
57
|
+
body: { workflowRunId: "run-1", clientBindingDigest },
|
|
58
|
+
}),
|
|
59
|
+
).resolves.toMatchObject({ status: "running", claimVersion: 1 });
|
|
60
|
+
expect(calls).toHaveLength(1);
|
|
61
|
+
expect(calls[0]).toMatchObject({
|
|
62
|
+
workflowRunId: "run-1",
|
|
63
|
+
clientBindingDigest,
|
|
64
|
+
principal,
|
|
65
|
+
guardContext,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("Governed Client Agent Workflow Artifact durable publication", () => {
|
|
71
|
+
test("completed retry 与 Worker 重启只按 terminal receipt publishArtifactOnce", async () => {
|
|
72
|
+
const { handler, beginCalls } = completedHandler();
|
|
73
|
+
const publications = new Map<
|
|
74
|
+
string,
|
|
75
|
+
{ readonly artifactRef: string; readonly sha256: string }
|
|
76
|
+
>();
|
|
77
|
+
let publishOnceCalls = 0;
|
|
78
|
+
const context = durableContext({
|
|
79
|
+
publishArtifactOnce: async (publicationKey, artifact) => {
|
|
80
|
+
publishOnceCalls++;
|
|
81
|
+
const existing = publications.get(publicationKey);
|
|
82
|
+
const value = {
|
|
83
|
+
artifactRef: artifact.artifactRef,
|
|
84
|
+
sha256: artifact.sha256,
|
|
85
|
+
};
|
|
86
|
+
if (existing && JSON.stringify(existing) !== JSON.stringify(value)) {
|
|
87
|
+
throw new Error("publication identity conflict");
|
|
88
|
+
}
|
|
89
|
+
publications.set(publicationKey, existing ?? value);
|
|
90
|
+
return {
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
publicationKey,
|
|
93
|
+
artifactId: "artifact-stable-1",
|
|
94
|
+
jobId: context.jobId,
|
|
95
|
+
stageKey: context.stageKey,
|
|
96
|
+
kind: artifact.kind,
|
|
97
|
+
artifactRef: artifact.artifactRef,
|
|
98
|
+
sha256: artifact.sha256,
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await handler.execute(jobPayload(), context);
|
|
104
|
+
await handler.execute(jobPayload(), context);
|
|
105
|
+
|
|
106
|
+
expect(beginCalls.value).toBe(2);
|
|
107
|
+
expect(publishOnceCalls).toBe(2);
|
|
108
|
+
expect(publications.size).toBe(1);
|
|
109
|
+
expect([...publications.keys()]).toEqual(["cawr_terminal-1"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("stable publication 未装配时 fail closed,不回退到可重放 publishArtifact", async () => {
|
|
113
|
+
const { handler } = completedHandler();
|
|
114
|
+
let fallbackCalls = 0;
|
|
115
|
+
const context = durableContext({
|
|
116
|
+
publishArtifact: async () => {
|
|
117
|
+
fallbackCalls++;
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await expect(handler.execute(jobPayload(), context)).rejects.toMatchObject({
|
|
122
|
+
code: "STABLE_ARTIFACT_PUBLICATION_UNAVAILABLE",
|
|
123
|
+
retryable: false,
|
|
124
|
+
});
|
|
125
|
+
expect(fallbackCalls).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function completedHandler() {
|
|
130
|
+
const beginCalls = { value: 0 };
|
|
131
|
+
const record = workflowRecord({
|
|
132
|
+
status: "completed",
|
|
133
|
+
claimGeneration: 1,
|
|
134
|
+
historySequence: 1,
|
|
135
|
+
historyHeadDigest: "sha256:history-1",
|
|
136
|
+
terminalReceiptId: "cawr_terminal-1",
|
|
137
|
+
terminalReceiptDigest: "sha256:terminal-1",
|
|
138
|
+
terminalResult: { result: { answer: "完成" } },
|
|
139
|
+
artifact: {
|
|
140
|
+
artifactRef: artifactRef(`blob_${"a".repeat(32)}`),
|
|
141
|
+
filename: workflowBinding.artifact.filename,
|
|
142
|
+
contentType: workflowBinding.artifact.contentType,
|
|
143
|
+
byteSize: 18,
|
|
144
|
+
sha256: "a".repeat(64),
|
|
145
|
+
},
|
|
146
|
+
finishedAt: 1_100,
|
|
147
|
+
});
|
|
148
|
+
const service = {
|
|
149
|
+
begin: async () => {
|
|
150
|
+
beginCalls.value++;
|
|
151
|
+
return { record, input: workflowInput, binding: workflowBinding };
|
|
152
|
+
},
|
|
153
|
+
} as unknown as GovernedClientAgentWorkflowArtifactService;
|
|
154
|
+
const registry = new DurableHandlerRegistry();
|
|
155
|
+
registerClientAgentWorkflowArtifactHandler({ registry, service });
|
|
156
|
+
const handler = registry.get(record.taskKey);
|
|
157
|
+
if (!handler) throw new Error("handler registration failed");
|
|
158
|
+
return { handler, beginCalls };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function jobPayload() {
|
|
162
|
+
const record = workflowRecord();
|
|
163
|
+
return {
|
|
164
|
+
commandId: record.commandId,
|
|
165
|
+
runId: record.runId,
|
|
166
|
+
nodeRunId: record.nodeRunId,
|
|
167
|
+
effectGeneration: record.effectGeneration,
|
|
168
|
+
requestDigest: record.requestDigest,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function durableContext(
|
|
173
|
+
patch: Partial<DurableHandlerContext>,
|
|
174
|
+
): DurableHandlerContext {
|
|
175
|
+
return {
|
|
176
|
+
jobId: "job-1",
|
|
177
|
+
stageKey: "main",
|
|
178
|
+
attemptNumber: 1,
|
|
179
|
+
signal: new AbortController().signal,
|
|
180
|
+
heartbeat: async () => undefined,
|
|
181
|
+
progress: async () => undefined,
|
|
182
|
+
publishArtifact: async () => {
|
|
183
|
+
throw new Error("unstable publication must not be used");
|
|
184
|
+
},
|
|
185
|
+
...patch,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function captureRoutes() {
|
|
190
|
+
const handlers = new Map<string, (context: unknown) => Promise<unknown>>();
|
|
191
|
+
const app = {
|
|
192
|
+
handlers,
|
|
193
|
+
get: () => undefined,
|
|
194
|
+
post: (path: string, handler: (context: unknown) => unknown) => {
|
|
195
|
+
handlers.set(path, async (context) => handler(context));
|
|
196
|
+
},
|
|
197
|
+
put: () => undefined,
|
|
198
|
+
delete: () => undefined,
|
|
199
|
+
} as unknown as RouteApp & { readonly handlers: typeof handlers };
|
|
200
|
+
return app;
|
|
201
|
+
}
|