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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClientAgentWorkflowArtifactRecord,
|
|
3
|
+
ClientAgentWorkflowClaimBinding,
|
|
4
|
+
} from "./contracts.js";
|
|
5
|
+
import type { ArtifactRef } from "@southwind-ai/storage";
|
|
6
|
+
import {
|
|
7
|
+
type ClientAgentWorkflowArtifactRepository,
|
|
8
|
+
sameWorkflowArtifactIdentity,
|
|
9
|
+
} from "./repository.js";
|
|
10
|
+
|
|
11
|
+
export class InMemoryClientAgentWorkflowArtifactRepository implements ClientAgentWorkflowArtifactRepository {
|
|
12
|
+
readonly #records = new Map<string, ClientAgentWorkflowArtifactRecord>();
|
|
13
|
+
|
|
14
|
+
async begin(record: ClientAgentWorkflowArtifactRecord) {
|
|
15
|
+
const existing = this.#records.get(record.workId);
|
|
16
|
+
if (existing) {
|
|
17
|
+
if (!sameWorkflowArtifactIdentity(existing, record)) throw conflict();
|
|
18
|
+
return clone(existing);
|
|
19
|
+
}
|
|
20
|
+
if (
|
|
21
|
+
[...this.#records.values()].some(
|
|
22
|
+
(value) =>
|
|
23
|
+
value.commandId === record.commandId ||
|
|
24
|
+
value.durableJobId === record.durableJobId,
|
|
25
|
+
)
|
|
26
|
+
) {
|
|
27
|
+
throw conflict();
|
|
28
|
+
}
|
|
29
|
+
this.#records.set(record.workId, clone(record));
|
|
30
|
+
return clone(record);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async find(workId: string) {
|
|
34
|
+
return optional(this.#records.get(workId));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async findByJob(durableJobId: string) {
|
|
38
|
+
return optional(
|
|
39
|
+
[...this.#records.values()].find(
|
|
40
|
+
(record) => record.durableJobId === durableJobId,
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async authorizeClientSessionAttempt(
|
|
46
|
+
input: Parameters<
|
|
47
|
+
ClientAgentWorkflowArtifactRepository["authorizeClientSessionAttempt"]
|
|
48
|
+
>[0],
|
|
49
|
+
) {
|
|
50
|
+
const current = [...this.#records.values()].find(
|
|
51
|
+
(record) => record.clientSessionId === input.clientSessionId,
|
|
52
|
+
);
|
|
53
|
+
if (!current) return "ordinary" as const;
|
|
54
|
+
return current.status === "claimed" &&
|
|
55
|
+
Boolean(current.claimExpiresAt && current.claimExpiresAt > input.now) &&
|
|
56
|
+
current.actorId === input.actorId &&
|
|
57
|
+
current.operationKey === input.operationKey &&
|
|
58
|
+
current.clientBindingDigest === input.clientBindingDigest &&
|
|
59
|
+
current.hostSessionDigest === input.hostSessionDigest
|
|
60
|
+
? ("current" as const)
|
|
61
|
+
: ("revoked" as const);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async findForActorRun(input: { actorId: string; runId: string }) {
|
|
65
|
+
return optional(
|
|
66
|
+
[...this.#records.values()].find(
|
|
67
|
+
(record) =>
|
|
68
|
+
record.actorId === input.actorId && record.runId === input.runId,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async claim(input: {
|
|
74
|
+
workId: string;
|
|
75
|
+
expectedVersion: number;
|
|
76
|
+
allowActiveTakeover?: boolean;
|
|
77
|
+
binding: ClientAgentWorkflowClaimBinding;
|
|
78
|
+
}) {
|
|
79
|
+
const current = this.#records.get(input.workId);
|
|
80
|
+
if (!current || current.version !== input.expectedVersion) {
|
|
81
|
+
return { outcome: "conflict" as const };
|
|
82
|
+
}
|
|
83
|
+
if (current.status === "completed" || current.status === "cancelled") {
|
|
84
|
+
return { outcome: current.status, record: clone(current) } as const;
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
current.status !== "pending" &&
|
|
88
|
+
(current.status !== "claimed" || !input.allowActiveTakeover)
|
|
89
|
+
) {
|
|
90
|
+
return { outcome: "busy" as const, record: clone(current) };
|
|
91
|
+
}
|
|
92
|
+
const previousClientSessionId = current.clientSessionId;
|
|
93
|
+
const updated: ClientAgentWorkflowArtifactRecord = {
|
|
94
|
+
...current,
|
|
95
|
+
...input.binding,
|
|
96
|
+
status: "claimed",
|
|
97
|
+
claimGeneration: current.claimGeneration + 1,
|
|
98
|
+
clientSessionId: undefined,
|
|
99
|
+
version: current.version + 1,
|
|
100
|
+
updatedAt: input.binding.now,
|
|
101
|
+
};
|
|
102
|
+
this.#records.set(current.workId, clone(updated));
|
|
103
|
+
return {
|
|
104
|
+
outcome: "claimed" as const,
|
|
105
|
+
record: clone(updated),
|
|
106
|
+
...(previousClientSessionId ? { previousClientSessionId } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async bindClientSession(input: {
|
|
111
|
+
workId: string;
|
|
112
|
+
claimGeneration: number;
|
|
113
|
+
clientSessionId: string;
|
|
114
|
+
now: number;
|
|
115
|
+
}) {
|
|
116
|
+
const current = this.#records.get(input.workId);
|
|
117
|
+
if (
|
|
118
|
+
!current ||
|
|
119
|
+
current.status !== "claimed" ||
|
|
120
|
+
current.claimGeneration !== input.claimGeneration ||
|
|
121
|
+
current.clientSessionId
|
|
122
|
+
) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const updated = {
|
|
126
|
+
...current,
|
|
127
|
+
clientSessionId: input.clientSessionId,
|
|
128
|
+
version: current.version + 1,
|
|
129
|
+
updatedAt: input.now,
|
|
130
|
+
};
|
|
131
|
+
this.#records.set(current.workId, clone(updated));
|
|
132
|
+
return clone(updated);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async complete(
|
|
136
|
+
input: Parameters<ClientAgentWorkflowArtifactRepository["complete"]>[0],
|
|
137
|
+
) {
|
|
138
|
+
const current = this.#records.get(input.workId);
|
|
139
|
+
if (
|
|
140
|
+
!current ||
|
|
141
|
+
current.status !== "claimed" ||
|
|
142
|
+
current.claimGeneration !== input.terminal.claimGeneration ||
|
|
143
|
+
current.clientSessionId !== input.terminal.clientSessionId ||
|
|
144
|
+
current.claimTokenHash !== input.claimTokenHash
|
|
145
|
+
) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
const updated: ClientAgentWorkflowArtifactRecord = {
|
|
149
|
+
...current,
|
|
150
|
+
...input.terminal,
|
|
151
|
+
status: "completed",
|
|
152
|
+
version: current.version + 1,
|
|
153
|
+
updatedAt: input.terminal.now,
|
|
154
|
+
finishedAt: input.terminal.now,
|
|
155
|
+
};
|
|
156
|
+
this.#records.set(current.workId, clone(updated));
|
|
157
|
+
return clone(updated);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async requestCancel(input: { workId: string; now: number }) {
|
|
161
|
+
const current = this.#records.get(input.workId);
|
|
162
|
+
if (!current) return undefined;
|
|
163
|
+
if (current.status === "cancelled") return clone(current);
|
|
164
|
+
if (current.status === "completed") return undefined;
|
|
165
|
+
if (current.status === "cancellation-requested") return clone(current);
|
|
166
|
+
const updated: ClientAgentWorkflowArtifactRecord = {
|
|
167
|
+
...current,
|
|
168
|
+
status: "cancellation-requested",
|
|
169
|
+
cancelRequestedAt: input.now,
|
|
170
|
+
version: current.version + 1,
|
|
171
|
+
updatedAt: input.now,
|
|
172
|
+
};
|
|
173
|
+
this.#records.set(current.workId, clone(updated));
|
|
174
|
+
return clone(updated);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async finalizeCancel(input: { workId: string; now: number }) {
|
|
178
|
+
const current = this.#records.get(input.workId);
|
|
179
|
+
if (!current) return undefined;
|
|
180
|
+
if (current.status === "cancelled") return clone(current);
|
|
181
|
+
if (current.status !== "cancellation-requested") return undefined;
|
|
182
|
+
const updated: ClientAgentWorkflowArtifactRecord = {
|
|
183
|
+
...current,
|
|
184
|
+
status: "cancelled",
|
|
185
|
+
finishedAt: input.now,
|
|
186
|
+
version: current.version + 1,
|
|
187
|
+
updatedAt: input.now,
|
|
188
|
+
};
|
|
189
|
+
this.#records.set(current.workId, clone(updated));
|
|
190
|
+
return clone(updated);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async listClaimedByHost(input: {
|
|
194
|
+
actorId: string;
|
|
195
|
+
hostSessionDigest: `sha256:${string}`;
|
|
196
|
+
}) {
|
|
197
|
+
return [...this.#records.values()]
|
|
198
|
+
.filter(
|
|
199
|
+
(record) =>
|
|
200
|
+
record.actorId === input.actorId &&
|
|
201
|
+
record.hostSessionDigest === input.hostSessionDigest &&
|
|
202
|
+
["claimed", "cancellation-requested"].includes(record.status),
|
|
203
|
+
)
|
|
204
|
+
.map(clone);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async listActiveArtifactRefs(_now: Date): Promise<ArtifactRef[]> {
|
|
208
|
+
return [...this.#records.values()].flatMap((record) =>
|
|
209
|
+
record.status === "completed" && record.artifact
|
|
210
|
+
? [record.artifact.artifactRef]
|
|
211
|
+
: [],
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async listCancellationRequested() {
|
|
216
|
+
return [...this.#records.values()]
|
|
217
|
+
.filter((record) => record.status === "cancellation-requested")
|
|
218
|
+
.map(clone);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function clone<T>(value: T): T {
|
|
223
|
+
return structuredClone(value);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function optional<T>(value: T | undefined) {
|
|
227
|
+
return value === undefined ? undefined : clone(value);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function conflict() {
|
|
231
|
+
return new Error("CLIENT_AGENT_WORKFLOW_IDENTITY_CONFLICT");
|
|
232
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ModuleClientAgentWorkflowArtifactBeginSpec,
|
|
3
|
+
ModuleClientAgentWorkflowArtifactCommand,
|
|
4
|
+
ModuleClientAgentWorkflowArtifactPort,
|
|
5
|
+
ModuleClientAgentWorkflowArtifactView,
|
|
6
|
+
} from "@southwind-ai/server-sdk";
|
|
7
|
+
import { digestJson } from "../client-session/digest.js";
|
|
8
|
+
import type {
|
|
9
|
+
ClientAgentWorkflowArtifactBinding,
|
|
10
|
+
ClientAgentWorkflowArtifactRecord,
|
|
11
|
+
} from "./contracts.js";
|
|
12
|
+
import { ClientAgentWorkflowArtifactError } from "./error.js";
|
|
13
|
+
import type { GovernedClientAgentWorkflowArtifactService } from "./service.js";
|
|
14
|
+
|
|
15
|
+
export class GovernedClientAgentWorkflowArtifactJobPort implements ModuleClientAgentWorkflowArtifactPort {
|
|
16
|
+
#record: ClientAgentWorkflowArtifactRecord;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly service: GovernedClientAgentWorkflowArtifactService,
|
|
20
|
+
private readonly jobId: string,
|
|
21
|
+
readonly binding: ClientAgentWorkflowArtifactBinding,
|
|
22
|
+
record: ClientAgentWorkflowArtifactRecord,
|
|
23
|
+
private readonly signal: AbortSignal,
|
|
24
|
+
) {
|
|
25
|
+
this.#record = record;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async begin(spec: ModuleClientAgentWorkflowArtifactBeginSpec) {
|
|
29
|
+
if (digestJson(spec.input) !== this.#record.inputDigest) throw invalid();
|
|
30
|
+
return view(this.#record, this.binding.artifact.kind);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async claimOrAdvance(command: ModuleClientAgentWorkflowArtifactCommand) {
|
|
34
|
+
this.requireCommand(command);
|
|
35
|
+
const current = await this.service.reconcileOrCancel({
|
|
36
|
+
jobId: this.jobId,
|
|
37
|
+
cancel: false,
|
|
38
|
+
signal: this.signal,
|
|
39
|
+
});
|
|
40
|
+
if (!current) throw invalid();
|
|
41
|
+
this.#record = current;
|
|
42
|
+
return view(current, this.binding.artifact.kind);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async reconcileOrCancel(command: ModuleClientAgentWorkflowArtifactCommand) {
|
|
46
|
+
this.requireCommand(command);
|
|
47
|
+
const current = await this.service.reconcileOrCancel({
|
|
48
|
+
jobId: this.jobId,
|
|
49
|
+
cancel: this.signal.aborted && this.signal.reason === "cancelled",
|
|
50
|
+
signal: this.signal,
|
|
51
|
+
});
|
|
52
|
+
if (!current) throw invalid();
|
|
53
|
+
this.#record = current;
|
|
54
|
+
return view(current, this.binding.artifact.kind);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get record() {
|
|
58
|
+
return this.#record;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private requireCommand(command: ModuleClientAgentWorkflowArtifactCommand) {
|
|
62
|
+
if (command.value !== this.#record.workId) throw invalid();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function view(
|
|
67
|
+
record: ClientAgentWorkflowArtifactRecord,
|
|
68
|
+
artifactKind: string,
|
|
69
|
+
): ModuleClientAgentWorkflowArtifactView {
|
|
70
|
+
const status =
|
|
71
|
+
record.status === "claimed"
|
|
72
|
+
? "running"
|
|
73
|
+
: record.status === "cancellation-requested"
|
|
74
|
+
? "cancelled"
|
|
75
|
+
: record.status;
|
|
76
|
+
return {
|
|
77
|
+
command: { value: record.workId },
|
|
78
|
+
status,
|
|
79
|
+
...(record.artifact
|
|
80
|
+
? {
|
|
81
|
+
artifact: {
|
|
82
|
+
artifactId: record.workId,
|
|
83
|
+
kind: artifactKind,
|
|
84
|
+
contentType: record.artifact.contentType,
|
|
85
|
+
filename: record.artifact.filename,
|
|
86
|
+
byteSize: record.artifact.byteSize,
|
|
87
|
+
sha256: record.artifact.sha256,
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
: {}),
|
|
91
|
+
...(status === "cancelled" ? { safeCode: "WORK_CANCELLED" as const } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function invalid() {
|
|
96
|
+
return new ClientAgentWorkflowArtifactError(
|
|
97
|
+
"NOT_FOUND",
|
|
98
|
+
"Workflow Client Agent command 已失效",
|
|
99
|
+
404,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ClientAgentWorkflowArtifactRecord } from "./contracts.js";
|
|
2
|
+
import type { ClientAgentWorkflowJobPayload } from "./workflow-loader.js";
|
|
3
|
+
|
|
4
|
+
export function clientWorkflowPayloadOf(
|
|
5
|
+
record: ClientAgentWorkflowArtifactRecord,
|
|
6
|
+
): ClientAgentWorkflowJobPayload {
|
|
7
|
+
return {
|
|
8
|
+
commandId: record.commandId,
|
|
9
|
+
runId: record.runId,
|
|
10
|
+
nodeRunId: record.nodeRunId,
|
|
11
|
+
effectGeneration: record.effectGeneration,
|
|
12
|
+
requestDigest: record.requestDigest,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function clientWorkflowPublicView(
|
|
17
|
+
record: ClientAgentWorkflowArtifactRecord,
|
|
18
|
+
artifactKind = "structured-data",
|
|
19
|
+
) {
|
|
20
|
+
const status =
|
|
21
|
+
record.status === "claimed" || record.status === "cancellation-requested"
|
|
22
|
+
? "running"
|
|
23
|
+
: record.status;
|
|
24
|
+
return {
|
|
25
|
+
workId: record.workId,
|
|
26
|
+
status,
|
|
27
|
+
claimVersion: record.claimGeneration,
|
|
28
|
+
...(record.status === "completed" && record.artifact
|
|
29
|
+
? {
|
|
30
|
+
artifact: {
|
|
31
|
+
artifactId: record.workId,
|
|
32
|
+
kind: artifactKind,
|
|
33
|
+
contentType: record.artifact.contentType,
|
|
34
|
+
filename: record.artifact.filename,
|
|
35
|
+
byteSize: record.artifact.byteSize,
|
|
36
|
+
sha256: record.artifact.sha256,
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
: {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { MemoryBlobStore } from "@southwind-ai/storage";
|
|
3
|
+
import type { GovernedClientAgentSessionService } from "../client-session/service.js";
|
|
4
|
+
import { digestText } from "../client-session/digest.js";
|
|
5
|
+
import { hostSessionDigest } from "../client-session/host-auth.js";
|
|
6
|
+
import type { ClientSessionRepository } from "../client-session/repository.js";
|
|
7
|
+
import { InMemoryClientAgentWorkflowArtifactRepository } from "./in-memory-repository.js";
|
|
8
|
+
import { GovernedClientAgentWorkflowArtifactService } from "./service.js";
|
|
9
|
+
import { ClientAgentWorkflowTaskAuthority } from "./task-authority.js";
|
|
10
|
+
import { fenceResumableClientWorkflowClaim } from "./takeover.js";
|
|
11
|
+
import {
|
|
12
|
+
clientBindingDigest,
|
|
13
|
+
guardContext,
|
|
14
|
+
principal,
|
|
15
|
+
workflowLoader,
|
|
16
|
+
workflowRecord,
|
|
17
|
+
workflowSignal,
|
|
18
|
+
} from "./workflow-artifact.test-support.js";
|
|
19
|
+
|
|
20
|
+
const claimToken = `cawc_${"t".repeat(43)}`;
|
|
21
|
+
|
|
22
|
+
describe("Governed Client Agent Workflow Artifact cancellation recovery", () => {
|
|
23
|
+
test("takeover 在清除旧 Session bridge 前先持久撤销", async () => {
|
|
24
|
+
const repository = new InMemoryClientAgentWorkflowArtifactRepository();
|
|
25
|
+
const claimed = await seedClaimed(repository, workflowRecord());
|
|
26
|
+
const revoked: string[] = [];
|
|
27
|
+
const allowed = await fenceResumableClientWorkflowClaim({
|
|
28
|
+
record: claimed,
|
|
29
|
+
binding: {
|
|
30
|
+
clientBindingDigest,
|
|
31
|
+
installationId: principal.installationId,
|
|
32
|
+
hostSessionDigest: hostSessionDigest(principal),
|
|
33
|
+
},
|
|
34
|
+
sessionRepository: {
|
|
35
|
+
find: async () => ({
|
|
36
|
+
attemptCount: 0,
|
|
37
|
+
activeRequests: 0,
|
|
38
|
+
receipts: [],
|
|
39
|
+
}),
|
|
40
|
+
} as unknown as ClientSessionRepository,
|
|
41
|
+
sessions: {
|
|
42
|
+
revokeSession: async (sessionId: string) => {
|
|
43
|
+
expect((await repository.find(claimed.workId))?.clientSessionId).toBe(
|
|
44
|
+
sessionId,
|
|
45
|
+
);
|
|
46
|
+
revoked.push(sessionId);
|
|
47
|
+
},
|
|
48
|
+
} as unknown as GovernedClientAgentSessionService,
|
|
49
|
+
repository,
|
|
50
|
+
now: 1_050,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(allowed).toBe(true);
|
|
54
|
+
expect(revoked).toEqual([claimed.clientSessionId]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("过期 takeover 在换代前撤销旧 Session,发现 attempt 时吸收取消", async () => {
|
|
58
|
+
const repository = new InMemoryClientAgentWorkflowArtifactRepository();
|
|
59
|
+
const claimed = await seedClaimed(repository, workflowRecord());
|
|
60
|
+
await expect(
|
|
61
|
+
fenceResumableClientWorkflowClaim({
|
|
62
|
+
record: claimed,
|
|
63
|
+
binding: {
|
|
64
|
+
clientBindingDigest: `sha256:${"x".repeat(64)}`,
|
|
65
|
+
installationId: "replacement-installation",
|
|
66
|
+
hostSessionDigest: `sha256:${"y".repeat(64)}`,
|
|
67
|
+
},
|
|
68
|
+
sessionRepository: {
|
|
69
|
+
find: async () => ({
|
|
70
|
+
attemptCount: 1,
|
|
71
|
+
activeRequests: 0,
|
|
72
|
+
receipts: [],
|
|
73
|
+
}),
|
|
74
|
+
} as unknown as ClientSessionRepository,
|
|
75
|
+
sessions: {
|
|
76
|
+
revokeSession: async (sessionId: string) => {
|
|
77
|
+
expect(
|
|
78
|
+
(await repository.find(claimed.workId))?.clientSessionId,
|
|
79
|
+
).toBe(sessionId);
|
|
80
|
+
},
|
|
81
|
+
} as unknown as GovernedClientAgentSessionService,
|
|
82
|
+
repository,
|
|
83
|
+
now: 61_000,
|
|
84
|
+
}),
|
|
85
|
+
).rejects.toMatchObject({ code: "CONFLICT" });
|
|
86
|
+
expect((await repository.find(claimed.workId))?.status).toBe("cancelled");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("取消先持久 cancellation-requested,再 revoke Session 并进入吸收态", async () => {
|
|
90
|
+
const repository = new InMemoryClientAgentWorkflowArtifactRepository();
|
|
91
|
+
const claimed = await seedClaimed(repository, workflowRecord());
|
|
92
|
+
const revokeStates: string[] = [];
|
|
93
|
+
const service = createService(repository, async (sessionId) => {
|
|
94
|
+
expect(sessionId).toBe(claimed.clientSessionId);
|
|
95
|
+
revokeStates.push((await repository.find(claimed.workId))!.status);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const cancelled = await service.cancelClaim({
|
|
99
|
+
workId: claimed.workId,
|
|
100
|
+
claimVersion: claimed.claimGeneration,
|
|
101
|
+
claimToken,
|
|
102
|
+
principal,
|
|
103
|
+
guardContext,
|
|
104
|
+
signal: workflowSignal,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(revokeStates).toEqual(["cancellation-requested"]);
|
|
108
|
+
expect(cancelled.status).toBe("cancelled");
|
|
109
|
+
expect((await repository.find(claimed.workId))?.status).toBe("cancelled");
|
|
110
|
+
await expect(
|
|
111
|
+
service.advance({
|
|
112
|
+
workId: claimed.workId,
|
|
113
|
+
claimVersion: claimed.claimGeneration,
|
|
114
|
+
claimToken,
|
|
115
|
+
receiptIds: ["late-receipt"],
|
|
116
|
+
principal,
|
|
117
|
+
guardContext,
|
|
118
|
+
signal: workflowSignal,
|
|
119
|
+
}),
|
|
120
|
+
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("host revoke 只取消同 Actor 与 host binding 的全部领取", async () => {
|
|
124
|
+
const repository = new InMemoryClientAgentWorkflowArtifactRepository();
|
|
125
|
+
const first = await seedClaimed(repository, workflowRecord());
|
|
126
|
+
const second = await seedClaimed(
|
|
127
|
+
repository,
|
|
128
|
+
workflowRecord({
|
|
129
|
+
workId: "cawa_work-2",
|
|
130
|
+
commandId: "command-2",
|
|
131
|
+
durableJobId: "job-2",
|
|
132
|
+
runId: "run-2",
|
|
133
|
+
nodeRunId: "node-run-2",
|
|
134
|
+
requestDigest: "sha256:request-2",
|
|
135
|
+
effectFenceTokenDigest: "sha256:fence-2",
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
const revoked: string[] = [];
|
|
139
|
+
const service = createService(repository, async (sessionId) => {
|
|
140
|
+
revoked.push(sessionId);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await expect(service.revokeHost(principal)).resolves.toBe(2);
|
|
144
|
+
expect(revoked.sort()).toEqual(
|
|
145
|
+
[first.clientSessionId!, second.clientSessionId!].sort(),
|
|
146
|
+
);
|
|
147
|
+
expect((await repository.find(first.workId))?.status).toBe("cancelled");
|
|
148
|
+
expect((await repository.find(second.workId))?.status).toBe("cancelled");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("host revoke 单条失败仍继续撤销同 Host 的后续领取", async () => {
|
|
152
|
+
const repository = new InMemoryClientAgentWorkflowArtifactRepository();
|
|
153
|
+
const first = await seedClaimed(repository, workflowRecord());
|
|
154
|
+
const second = await seedClaimed(
|
|
155
|
+
repository,
|
|
156
|
+
workflowRecord({
|
|
157
|
+
workId: "cawa_work-host-retry",
|
|
158
|
+
commandId: "command-host-retry",
|
|
159
|
+
durableJobId: "job-host-retry",
|
|
160
|
+
runId: "run-host-retry",
|
|
161
|
+
nodeRunId: "node-run-host-retry",
|
|
162
|
+
requestDigest: "sha256:request-host-retry",
|
|
163
|
+
effectFenceTokenDigest: "sha256:fence-host-retry",
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
const calls: string[] = [];
|
|
167
|
+
const service = createService(repository, async (sessionId) => {
|
|
168
|
+
calls.push(sessionId);
|
|
169
|
+
if (sessionId === first.clientSessionId) throw new Error("host offline");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await expect(service.revokeHost(principal)).rejects.toBeInstanceOf(
|
|
173
|
+
AggregateError,
|
|
174
|
+
);
|
|
175
|
+
expect(calls.sort()).toEqual(
|
|
176
|
+
[first.clientSessionId!, second.clientSessionId!].sort(),
|
|
177
|
+
);
|
|
178
|
+
expect((await repository.find(second.workId))?.status).toBe("cancelled");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("重启从稳定 cancellation intent 恢复,不重新 create/finalize Session", async () => {
|
|
182
|
+
const repository = new InMemoryClientAgentWorkflowArtifactRepository();
|
|
183
|
+
const claimed = await seedClaimed(repository, workflowRecord());
|
|
184
|
+
await repository.requestCancel({ workId: claimed.workId, now: 1_100 });
|
|
185
|
+
const calls: string[] = [];
|
|
186
|
+
|
|
187
|
+
const restarted = createService(repository, async (sessionId, cause) => {
|
|
188
|
+
calls.push(`revoke:${sessionId}:${cause}`);
|
|
189
|
+
expect((await repository.find(claimed.workId))?.status).toBe(
|
|
190
|
+
"cancellation-requested",
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
const recovered = await restarted.reconcileOrCancel({
|
|
194
|
+
jobId: claimed.durableJobId,
|
|
195
|
+
cancel: false,
|
|
196
|
+
signal: workflowSignal,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(calls).toEqual([
|
|
200
|
+
`revoke:${claimed.clientSessionId}:workflow-cancel-recovery`,
|
|
201
|
+
]);
|
|
202
|
+
expect(recovered?.status).toBe("cancelled");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
async function seedClaimed(
|
|
207
|
+
repository: InMemoryClientAgentWorkflowArtifactRepository,
|
|
208
|
+
record: ReturnType<typeof workflowRecord>,
|
|
209
|
+
) {
|
|
210
|
+
const pending = await repository.begin(record);
|
|
211
|
+
const claimed = await repository.claim({
|
|
212
|
+
workId: pending.workId,
|
|
213
|
+
expectedVersion: pending.version,
|
|
214
|
+
binding: {
|
|
215
|
+
clientBindingDigest,
|
|
216
|
+
installationId: principal.installationId,
|
|
217
|
+
hostSessionDigest: hostSessionDigest(principal),
|
|
218
|
+
claimTokenHash: digestText(claimToken),
|
|
219
|
+
claimExpiresAt: 60_000,
|
|
220
|
+
now: 1_001,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
if (claimed.outcome !== "claimed") throw new Error("claim failed");
|
|
224
|
+
const bound = await repository.bindClientSession({
|
|
225
|
+
workId: pending.workId,
|
|
226
|
+
claimGeneration: claimed.record.claimGeneration,
|
|
227
|
+
clientSessionId: `cas_${pending.workId}`,
|
|
228
|
+
now: 1_002,
|
|
229
|
+
});
|
|
230
|
+
if (!bound) throw new Error("session bind failed");
|
|
231
|
+
return bound;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function createService(
|
|
235
|
+
repository: InMemoryClientAgentWorkflowArtifactRepository,
|
|
236
|
+
revoke: (sessionId: string, cause: string) => Promise<void>,
|
|
237
|
+
) {
|
|
238
|
+
const sessions = {
|
|
239
|
+
revokeSession: revoke,
|
|
240
|
+
} as unknown as GovernedClientAgentSessionService;
|
|
241
|
+
const sessionRepository = {
|
|
242
|
+
find: async () => undefined,
|
|
243
|
+
} as unknown as ClientSessionRepository;
|
|
244
|
+
return new GovernedClientAgentWorkflowArtifactService(
|
|
245
|
+
repository,
|
|
246
|
+
workflowLoader(),
|
|
247
|
+
sessions,
|
|
248
|
+
sessionRepository,
|
|
249
|
+
new MemoryBlobStore(),
|
|
250
|
+
new ClientAgentWorkflowTaskAuthority(),
|
|
251
|
+
() => 2_000,
|
|
252
|
+
);
|
|
253
|
+
}
|