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.
Files changed (160) hide show
  1. package/dist/cli.js +215 -24
  2. package/package.json +1 -1
  3. package/template/.agents/skills/windy-business-module/SKILL.md +58 -8
  4. package/template/.windy-template.json +2 -2
  5. package/template/apps/server/src/agent/client-session/attempt-authority.test.ts +162 -0
  6. package/template/apps/server/src/agent/client-session/attempt-authority.ts +191 -0
  7. package/template/apps/server/src/agent/client-session/bootstrap.ts +27 -11
  8. package/template/apps/server/src/agent/client-session/completion.ts +75 -24
  9. package/template/apps/server/src/agent/client-session/contracts.ts +23 -0
  10. package/template/apps/server/src/agent/client-session/creation.ts +103 -0
  11. package/template/apps/server/src/agent/client-session/digest.ts +11 -1
  12. package/template/apps/server/src/agent/client-session/drizzle-records.ts +150 -0
  13. package/template/apps/server/src/agent/client-session/drizzle-repository.integration.test.ts +30 -3
  14. package/template/apps/server/src/agent/client-session/drizzle-repository.ts +23 -132
  15. package/template/apps/server/src/agent/client-session/finalization.ts +99 -0
  16. package/template/apps/server/src/agent/client-session/host-auth.ts +22 -10
  17. package/template/apps/server/src/agent/client-session/host-revocation-composition.test.ts +77 -0
  18. package/template/apps/server/src/agent/client-session/host-revocation-composition.ts +28 -0
  19. package/template/apps/server/src/agent/client-session/launch-authority.contract.test.ts +21 -0
  20. package/template/apps/server/src/agent/client-session/manifest-admission.ts +11 -0
  21. package/template/apps/server/src/agent/client-session/service.test-support.ts +22 -0
  22. package/template/apps/server/src/agent/client-session/service.ts +104 -134
  23. package/template/apps/server/src/agent/client-session/terminal-digest.test.ts +23 -0
  24. package/template/apps/server/src/agent/client-workflow-artifact/active-blob-references.ts +8 -0
  25. package/template/apps/server/src/agent/client-workflow-artifact/artifact-builder.ts +60 -0
  26. package/template/apps/server/src/agent/client-workflow-artifact/authority-current.ts +59 -0
  27. package/template/apps/server/src/agent/client-workflow-artifact/binding-registry.test.ts +189 -0
  28. package/template/apps/server/src/agent/client-workflow-artifact/binding-registry.ts +153 -0
  29. package/template/apps/server/src/agent/client-workflow-artifact/bootstrap.ts +77 -0
  30. package/template/apps/server/src/agent/client-workflow-artifact/cancellation.ts +47 -0
  31. package/template/apps/server/src/agent/client-workflow-artifact/completed-recovery.ts +31 -0
  32. package/template/apps/server/src/agent/client-workflow-artifact/contracts.ts +104 -0
  33. package/template/apps/server/src/agent/client-workflow-artifact/drizzle-locks.ts +30 -0
  34. package/template/apps/server/src/agent/client-workflow-artifact/drizzle-mapper.ts +138 -0
  35. package/template/apps/server/src/agent/client-workflow-artifact/drizzle-repository.ts +346 -0
  36. package/template/apps/server/src/agent/client-workflow-artifact/error.ts +21 -0
  37. package/template/apps/server/src/agent/client-workflow-artifact/handler.ts +167 -0
  38. package/template/apps/server/src/agent/client-workflow-artifact/host-revocation.ts +29 -0
  39. package/template/apps/server/src/agent/client-workflow-artifact/in-memory-repository.ts +232 -0
  40. package/template/apps/server/src/agent/client-workflow-artifact/job-port.ts +101 -0
  41. package/template/apps/server/src/agent/client-workflow-artifact/presentation.ts +41 -0
  42. package/template/apps/server/src/agent/client-workflow-artifact/recovery.contract.test.ts +253 -0
  43. package/template/apps/server/src/agent/client-workflow-artifact/release-binding.ts +85 -0
  44. package/template/apps/server/src/agent/client-workflow-artifact/repository.test.ts +204 -0
  45. package/template/apps/server/src/agent/client-workflow-artifact/repository.ts +97 -0
  46. package/template/apps/server/src/agent/client-workflow-artifact/routes-handler.contract.test.ts +201 -0
  47. package/template/apps/server/src/agent/client-workflow-artifact/routes.ts +161 -0
  48. package/template/apps/server/src/agent/client-workflow-artifact/runtime-composition.ts +101 -0
  49. package/template/apps/server/src/agent/client-workflow-artifact/scope-authority.ts +39 -0
  50. package/template/apps/server/src/agent/client-workflow-artifact/service-authorization.ts +117 -0
  51. package/template/apps/server/src/agent/client-workflow-artifact/service-support.ts +28 -0
  52. package/template/apps/server/src/agent/client-workflow-artifact/service.contract.test.ts +345 -0
  53. package/template/apps/server/src/agent/client-workflow-artifact/service.ts +350 -0
  54. package/template/apps/server/src/agent/client-workflow-artifact/session-boundary.ts +39 -0
  55. package/template/apps/server/src/agent/client-workflow-artifact/source-access.contract.test.ts +98 -0
  56. package/template/apps/server/src/agent/client-workflow-artifact/source-access.ts +80 -0
  57. package/template/apps/server/src/agent/client-workflow-artifact/takeover.ts +122 -0
  58. package/template/apps/server/src/agent/client-workflow-artifact/task-authority.contract.test.ts +97 -0
  59. package/template/apps/server/src/agent/client-workflow-artifact/task-authority.ts +147 -0
  60. package/template/apps/server/src/agent/client-workflow-artifact/terminal-authority.contract.test.ts +100 -0
  61. package/template/apps/server/src/agent/client-workflow-artifact/terminal-replay.ts +31 -0
  62. package/template/apps/server/src/agent/client-workflow-artifact/terminal-seal.ts +211 -0
  63. package/template/apps/server/src/agent/client-workflow-artifact/workflow-artifact.test-support.ts +173 -0
  64. package/template/apps/server/src/agent/client-workflow-artifact/workflow-loader.contract.test.ts +254 -0
  65. package/template/apps/server/src/agent/client-workflow-artifact/workflow-loader.ts +285 -0
  66. package/template/apps/server/src/agent/run-facade.test-support.ts +17 -4
  67. package/template/apps/server/src/agent/run-facade.test.ts +16 -0
  68. package/template/apps/server/src/agent/run-session-authorization.ts +12 -10
  69. package/template/apps/server/src/agent/runtime-bootstrap-input.ts +5 -1
  70. package/template/apps/server/src/agent/runtime-bootstrap.ts +33 -33
  71. package/template/apps/server/src/agent/runtime-public-ports.ts +40 -0
  72. package/template/apps/server/src/agent/session-invalidation/contracts.ts +25 -26
  73. package/template/apps/server/src/agent/session-projection-access.ts +31 -19
  74. package/template/apps/server/src/agent/unavailable-routes.ts +21 -0
  75. package/template/apps/server/src/agent/workflow-runtime-bootstrap.ts +2 -0
  76. package/template/apps/server/src/agent-workflow/artifact-source-authorization.ts +12 -6
  77. package/template/apps/server/src/agent-workflow/command-facade.ts +70 -11
  78. package/template/apps/server/src/agent-workflow/drizzle-artifact-port.ts +9 -9
  79. package/template/apps/server/src/agent-workflow/drizzle-delegation-completion.ts +1 -1
  80. package/template/apps/server/src/agent-workflow/platform-node-adapters.ts +14 -6
  81. package/template/apps/server/src/agent-workflow/project-launch-authority.contract.test.ts +53 -0
  82. package/template/apps/server/src/agent-workflow/project-launch-authority.ts +92 -0
  83. package/template/apps/server/src/agent-workflow/runtime-composition.ts +30 -14
  84. package/template/apps/server/src/app/agent-runtime-composition.ts +6 -1
  85. package/template/apps/server/src/app/client-agent-workflow-artifact-binding.ts +14 -0
  86. package/template/apps/server/src/application-services.ts +6 -0
  87. package/template/apps/server/src/data-access/scope-containment.test.ts +31 -0
  88. package/template/apps/server/src/data-access/scope-containment.ts +48 -0
  89. package/template/apps/server/src/jobs/artifact-publication.test.ts +192 -0
  90. package/template/apps/server/src/jobs/artifact-publication.ts +56 -0
  91. package/template/apps/server/src/jobs/artifact-publisher.ts +101 -0
  92. package/template/apps/server/src/jobs/drizzle-artifact.ts +73 -1
  93. package/template/apps/server/src/jobs/drizzle-execution-repository.ts +14 -1
  94. package/template/apps/server/src/jobs/drizzle-execution.integration.test.ts +65 -0
  95. package/template/apps/server/src/jobs/drizzle-repository.integration.test.ts +1 -0
  96. package/template/apps/server/src/jobs/execution-repository.ts +28 -0
  97. package/template/apps/server/src/jobs/execution.test.ts +3 -1
  98. package/template/apps/server/src/jobs/in-memory-execution.ts +16 -0
  99. package/template/apps/server/src/jobs/in-memory-lease.ts +45 -1
  100. package/template/apps/server/src/jobs/legacy-adapter.integration.test.ts +2 -0
  101. package/template/apps/server/src/jobs/postgres-cleanup-contract.test.ts +58 -0
  102. package/template/apps/server/src/jobs/worker.ts +8 -14
  103. package/template/apps/server/src/module-host/background-ai-unavailable.ts +18 -0
  104. package/template/apps/server/src/module-host/durable-context.ts +19 -22
  105. package/template/apps/server/src/module-host/implementation-completeness.test.ts +80 -1
  106. package/template/apps/server/src/module-host/implementation-completeness.ts +28 -1
  107. package/template/apps/server/src/module-host/request-context.ts +32 -8
  108. package/template/apps/server/src/module-host/storage-port.ts +3 -0
  109. package/template/apps/server/src/module-host/workflow-input-artifact-port.test.ts +104 -0
  110. package/template/apps/server/src/module-host/workflow-input-artifact-port.ts +229 -0
  111. package/template/apps/server/src/module-host/workflow-input-materialization.test.ts +163 -0
  112. package/template/apps/server/src/module-host/workflow-input-materialization.ts +161 -0
  113. package/template/apps/server/src/workflow/drizzle-repository.integration.test.ts +1 -0
  114. package/template/docs/architecture/agent-workflow.md +13 -2
  115. package/template/docs/architecture/durable-jobs.md +7 -0
  116. package/template/docs/operations/agent-session-memory.md +2 -1
  117. package/template/docs/operations/agent-session-troubleshooting.md +87 -2
  118. package/template/docs/operations/agent-workflow-recovery.md +25 -14
  119. package/template/docs/platform/client-agent-session.md +148 -2
  120. package/template/packages/ai-client/package.json +2 -1
  121. package/template/packages/ai-client/src/client-agent/index.ts +1 -0
  122. package/template/packages/ai-client/src/client-agent/workflow-artifact/contracts.ts +85 -0
  123. package/template/packages/ai-client/src/client-agent/workflow-artifact/http.test.ts +49 -0
  124. package/template/packages/ai-client/src/client-agent/workflow-artifact/http.ts +82 -0
  125. package/template/packages/ai-client/src/client-agent/workflow-artifact/index.ts +3 -0
  126. package/template/packages/ai-client/src/client-agent/workflow-artifact/validation.test.ts +154 -0
  127. package/template/packages/ai-client/src/client-agent/workflow-artifact/validation.ts +332 -0
  128. package/template/packages/database/drizzle/0062_client_agent_workflow_artifact.sql +63 -0
  129. package/template/packages/database/drizzle/meta/0062_snapshot.json +14839 -0
  130. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  131. package/template/packages/database/package.json +1 -1
  132. package/template/packages/database/src/agent-runtime-schema.test.ts +177 -0
  133. package/template/packages/database/src/schema/client-agent-workflow-artifact.ts +158 -0
  134. package/template/packages/database/src/schema/index.ts +1 -0
  135. package/template/packages/jobs/package.json +1 -1
  136. package/template/packages/jobs/src/handlers.test.ts +10 -0
  137. package/template/packages/jobs/src/handlers.ts +28 -11
  138. package/template/packages/modules/package.json +1 -1
  139. package/template/packages/modules/src/ai-client-execution-policy.ts +6 -0
  140. package/template/packages/modules/src/manifest.ts +2 -0
  141. package/template/packages/modules/src/system-agent-audit-actions.ts +3 -0
  142. package/template/packages/modules/src/system-ai-api-bindings.ts +15 -0
  143. package/template/packages/modules/src/system-audit-actions.ts +15 -0
  144. package/template/packages/server-sdk/index.ts +52 -0
  145. package/template/packages/server-sdk/package.json +5 -1
  146. package/template/packages/server-sdk/src/client-agent-session.ts +13 -1
  147. package/template/packages/server-sdk/src/client-agent-workflow-artifact.contract.test.ts +113 -0
  148. package/template/packages/server-sdk/src/client-agent-workflow-artifact.ts +183 -0
  149. package/template/packages/server-sdk/src/durable-handler.ts +7 -0
  150. package/template/packages/server-sdk/src/request-context.ts +7 -0
  151. package/template/packages/server-sdk/src/workflow-input-artifact-port.ts +104 -0
  152. package/template/packages/server-sdk/src/workflow-input-artifact-validation.contract.test.ts +124 -0
  153. package/template/packages/server-sdk/src/workflow-input-artifact-validation.ts +347 -0
  154. package/template/packages/server-sdk/src/workflow-input-materialization-handler.ts +43 -0
  155. package/template/packages/storage/package.json +1 -1
  156. package/template/packages/storage/src/blob-store-put-once.test.ts +70 -0
  157. package/template/packages/storage/src/blob-store.ts +9 -0
  158. package/template/packages/storage/src/local-file-blob-put-once.ts +120 -0
  159. package/template/packages/storage/src/local-file-blob-store.ts +53 -1
  160. package/template/packages/storage/src/memory-blob-store.ts +73 -33
@@ -0,0 +1,77 @@
1
+ import { expect, test } from "bun:test";
2
+ import type {
3
+ ModuleClientAgentHostAuthAdapter,
4
+ ModuleClientAgentHostPrincipal,
5
+ } from "@southwind-ai/server-sdk";
6
+ import { bindClientAgentHostRevocations } from "./host-revocation-composition.js";
7
+
8
+ test("单监听 Host Auth Adapter 通过 Composition fan-out 撤销 Session 与 Workflow", async () => {
9
+ let listener:
10
+ | ((principal: ModuleClientAgentHostPrincipal) => Promise<void> | void)
11
+ | undefined;
12
+ const adapter = {
13
+ ready: async () => true,
14
+ authenticate: async () => undefined,
15
+ bindRevocations(next) {
16
+ listener = next;
17
+ return () => {
18
+ listener = undefined;
19
+ };
20
+ },
21
+ } satisfies ModuleClientAgentHostAuthAdapter;
22
+ const calls: string[] = [];
23
+ const dispose = bindClientAgentHostRevocations({
24
+ adapter,
25
+ revokeSessionHost: async () => {
26
+ calls.push("session");
27
+ },
28
+ revokeWorkflowHost: async () => {
29
+ calls.push("workflow");
30
+ },
31
+ });
32
+
33
+ await listener?.({
34
+ actorId: "actor-1",
35
+ installationId: "installation-1",
36
+ sessionId: "host-session-1",
37
+ });
38
+ expect(calls).toEqual(["session", "workflow"]);
39
+ dispose?.();
40
+ expect(listener).toBeUndefined();
41
+ });
42
+
43
+ test("Session 撤销失败时仍尝试 Workflow 撤销并汇总失败", async () => {
44
+ let listener:
45
+ | ((principal: ModuleClientAgentHostPrincipal) => Promise<void> | void)
46
+ | undefined;
47
+ const adapter = {
48
+ ready: async () => true,
49
+ authenticate: async () => undefined,
50
+ bindRevocations(next) {
51
+ listener = next;
52
+ return () => {
53
+ listener = undefined;
54
+ };
55
+ },
56
+ } satisfies ModuleClientAgentHostAuthAdapter;
57
+ const calls: string[] = [];
58
+ bindClientAgentHostRevocations({
59
+ adapter,
60
+ revokeSessionHost: async () => {
61
+ calls.push("session");
62
+ throw new Error("session revoke failed");
63
+ },
64
+ revokeWorkflowHost: async () => {
65
+ calls.push("workflow");
66
+ },
67
+ });
68
+
69
+ await expect(
70
+ listener?.({
71
+ actorId: "actor-1",
72
+ installationId: "installation-1",
73
+ sessionId: "host-session-1",
74
+ }),
75
+ ).rejects.toBeInstanceOf(AggregateError);
76
+ expect(calls).toEqual(["session", "workflow"]);
77
+ });
@@ -0,0 +1,28 @@
1
+ import type {
2
+ ModuleClientAgentHostAuthAdapter,
3
+ ModuleClientAgentHostPrincipal,
4
+ } from "@southwind-ai/server-sdk";
5
+
6
+ export function bindClientAgentHostRevocations(input: {
7
+ adapter?: ModuleClientAgentHostAuthAdapter;
8
+ revokeSessionHost?(
9
+ principal: ModuleClientAgentHostPrincipal,
10
+ ): Promise<unknown>;
11
+ revokeWorkflowHost?(
12
+ principal: ModuleClientAgentHostPrincipal,
13
+ ): Promise<unknown>;
14
+ }) {
15
+ return input.adapter?.bindRevocations(async (principal) => {
16
+ const errors: unknown[] = [];
17
+ for (const revoke of [input.revokeSessionHost, input.revokeWorkflowHost]) {
18
+ try {
19
+ await revoke?.(principal);
20
+ } catch (error) {
21
+ errors.push(error);
22
+ }
23
+ }
24
+ if (errors.length) {
25
+ throw new AggregateError(errors, "Client Agent Host 撤销未完全收敛");
26
+ }
27
+ });
28
+ }
@@ -0,0 +1,21 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createFixture } from "./service.test-support.js";
3
+ import { binding, hostSession } from "./service.test-support.js";
4
+
5
+ test("普通 Client Session create 不能启动 workflow-only Operation", async () => {
6
+ const fixture = await createFixture();
7
+ const input = {
8
+ operationKey: "analysis.page-agent",
9
+ input: { question: "绕过 Workflow" },
10
+ clientBindingDigest: binding,
11
+ hostSessionDigest: hostSession,
12
+ actorId: "actor-1",
13
+ } as const;
14
+
15
+ await expect(fixture.service.create(input)).rejects.toMatchObject({
16
+ code: "FORBIDDEN",
17
+ });
18
+ await expect(fixture.service.createWorkflow(input)).resolves.toMatchObject({
19
+ capabilities: ["pageAgentClientRun"],
20
+ });
21
+ });
@@ -63,8 +63,19 @@ export class ManifestClientSessionAdmission implements ClientSessionAdmissionPor
63
63
  input: ModuleAiJsonValue;
64
64
  clientBindingDigest: `sha256:${string}`;
65
65
  actorId: string;
66
+ launchAuthority: "direct" | "workflow-artifact";
66
67
  }) {
67
68
  const operation = this.operation(input.operationKey);
69
+ if (
70
+ (operation.definition.clientExecution!.launchAuthority ?? "direct") !==
71
+ input.launchAuthority
72
+ ) {
73
+ throw new ClientAgentSessionError(
74
+ "FORBIDDEN",
75
+ "Operation 不允许从当前入口启动 Client Agent Session",
76
+ 403,
77
+ );
78
+ }
68
79
  const context = await this.resolveContext(input.actorId);
69
80
  await this.authorize(operation, context, input.actorId);
70
81
  const inputBytes = new TextEncoder().encode(
@@ -2,6 +2,7 @@ import type { ModuleAiJsonValue } from "@southwind-ai/server-sdk";
2
2
  import type {
3
3
  ClientAgentProviderPort,
4
4
  ClientSessionAdmissionPort,
5
+ ClientSessionAttemptAuthorityChecker,
5
6
  ClientSessionAuditPort,
6
7
  ClientSessionCaller,
7
8
  ClientSessionRecord,
@@ -10,6 +11,7 @@ import { digestJson } from "./digest.js";
10
11
  import { ClientAgentSessionError } from "./errors.js";
11
12
  import { InMemoryClientSessionRepository } from "./repository.js";
12
13
  import { GovernedClientAgentSessionService } from "./service.js";
14
+ import { ClientSessionAttemptAuthorityBinding } from "./attempt-authority.js";
13
15
 
14
16
  export const binding = `sha256:${"b".repeat(64)}` as const;
15
17
  export const hostSession = `sha256:${"e".repeat(64)}` as const;
@@ -26,6 +28,7 @@ export async function createFixture(
26
28
  signal: AbortSignal,
27
29
  ) => Promise<ModuleAiJsonValue>;
28
30
  providerReady?: boolean;
31
+ attemptAuthority?: ClientSessionAttemptAuthorityChecker;
29
32
  } = {},
30
33
  ) {
31
34
  const clock = { value: options.now ?? 1_000 };
@@ -39,6 +42,10 @@ export async function createFixture(
39
42
  options.providerReady ?? true,
40
43
  );
41
44
  const audits: Parameters<ClientSessionAuditPort["record"]>[0][] = [];
45
+ const attemptAuthority = new ClientSessionAttemptAuthorityBinding();
46
+ if (options.attemptAuthority) {
47
+ attemptAuthority.bind(options.attemptAuthority);
48
+ }
42
49
  const service = new GovernedClientAgentSessionService(
43
50
  repository,
44
51
  admission,
@@ -49,6 +56,7 @@ export async function createFixture(
49
56
  },
50
57
  },
51
58
  "https://windy.example/api/agent/client-sessions/",
59
+ attemptAuthority,
52
60
  () => clock.value,
53
61
  options.maxTtlMs,
54
62
  );
@@ -70,6 +78,7 @@ export async function createFixture(
70
78
  repository,
71
79
  admission,
72
80
  provider,
81
+ attemptAuthority,
73
82
  audits,
74
83
  grant,
75
84
  caller,
@@ -90,7 +99,19 @@ class FakeAdmission implements ClientSessionAdmissionPort {
90
99
  input: ModuleAiJsonValue;
91
100
  clientBindingDigest: `sha256:${string}`;
92
101
  actorId: string;
102
+ launchAuthority: "direct" | "workflow-artifact";
93
103
  }) {
104
+ const expectedAuthority: "direct" | "workflow-artifact" =
105
+ input.operationKey === "analysis.page-agent"
106
+ ? "workflow-artifact"
107
+ : "direct";
108
+ if (input.launchAuthority !== expectedAuthority) {
109
+ throw new ClientAgentSessionError(
110
+ "FORBIDDEN",
111
+ "launch authority mismatch",
112
+ 403,
113
+ );
114
+ }
94
115
  return {
95
116
  actorId: input.actorId,
96
117
  operationKey: input.operationKey,
@@ -121,6 +142,7 @@ class FakeAdmission implements ClientSessionAdmissionPort {
121
142
  },
122
143
  limits: {
123
144
  mode: "governed-session" as const,
145
+ launchAuthority: expectedAuthority,
124
146
  maxSteps: this.maxSteps,
125
147
  maxRequestBytes: 16_384,
126
148
  maxResponseBytes: 16_384,
@@ -1,31 +1,25 @@
1
- import { randomBytes, randomUUID } from "node:crypto";
2
1
  import type { ModuleAiJsonValue } from "@southwind-ai/server-sdk";
3
2
  import type {
4
3
  ClientSessionAdmissionPort,
4
+ ClientSessionAttemptAuthorityChecker,
5
5
  ClientSessionAuditPort,
6
6
  ClientSessionCaller,
7
- ClientSessionFinalResult,
8
7
  ClientSessionRecord,
9
8
  } from "./contracts.js";
10
- import { digestJson, digestText, validDigest } from "./digest.js";
9
+ import { digestText } from "./digest.js";
11
10
  import type { ClientSessionRepository } from "./repository.js";
12
11
  import { clientAgentCapabilities } from "./capabilities.js";
13
12
  import { authorizeClientSession } from "./authorization.js";
14
13
  import { abortClientSession, auditRevokedSessions } from "./revocation.js";
15
- import {
16
- ensureTrailingSlash,
17
- publicLimits,
18
- sessionView,
19
- } from "./presentation.js";
20
- import {
21
- ClientAgentSessionError,
22
- clientSessionConflict as conflict,
23
- clientSessionInvalid as invalid,
24
- } from "./errors.js";
14
+ import { sessionView } from "./presentation.js";
15
+ import { clientSessionConflict as conflict } from "./errors.js";
25
16
  import {
26
17
  consumeClientAgentCompletion,
27
18
  runClientAgentCompletion,
28
19
  } from "./completion.js";
20
+ import { finalizeClientSession } from "./finalization.js";
21
+ import { createClientAgentSession } from "./creation.js";
22
+
29
23
  export class GovernedClientAgentSessionService {
30
24
  readonly #attemptControllers = new Map<string, AbortController>();
31
25
  readonly #recovery: Promise<number>;
@@ -35,6 +29,7 @@ export class GovernedClientAgentSessionService {
35
29
  private readonly provider: import("./contracts.js").ClientAgentProviderPort,
36
30
  private readonly audit: ClientSessionAuditPort,
37
31
  private readonly trustedBaseUrl: string,
32
+ private readonly attemptAuthority: ClientSessionAttemptAuthorityChecker,
38
33
  private readonly now: () => number = Date.now,
39
34
  private readonly maxTtlMs = 5 * 60_000,
40
35
  ) {
@@ -60,73 +55,44 @@ export class GovernedClientAgentSessionService {
60
55
  hostSessionDigest: `sha256:${string}`;
61
56
  actorId: string;
62
57
  }) {
63
- await this.#recovery;
64
- if (!(await this.provider.ready())) {
65
- throw new ClientAgentSessionError(
66
- "PROVIDER_FAILED",
67
- "Client Agent Provider 尚未就绪",
68
- 503,
69
- );
70
- }
71
- if (
72
- !validDigest(input.clientBindingDigest) ||
73
- !validDigest(input.hostSessionDigest) ||
74
- !input.actorId.trim()
75
- ) {
76
- throw invalid("Client Agent binding 或 Actor 无效");
77
- }
78
- const admission = await this.admission.admit({
79
- operationKey: input.operationKey,
80
- input: input.input,
81
- actorId: input.actorId,
82
- clientBindingDigest: input.clientBindingDigest,
83
- });
84
- if (
85
- admission.actorId !== input.actorId ||
86
- admission.operationKey !== input.operationKey ||
87
- admission.clientBindingDigest !== input.clientBindingDigest
88
- ) {
89
- throw new Error("Client Agent admission 返回了不一致的绑定");
90
- }
91
- const createdAt = this.now();
92
- const expiresAt =
93
- createdAt +
94
- Math.min(admission.limits.maxDurationSeconds * 1_000, this.maxTtlMs);
95
- const accessToken = `car_${randomBytes(32).toString("base64url")}`;
96
- const record: ClientSessionRecord = {
97
- ...admission,
98
- sessionId: `cas_${randomUUID()}`,
99
- clientRunId: `carun_${randomUUID()}`,
100
- tokenHash: digestText(accessToken),
101
- hostSessionDigest: input.hostSessionDigest,
102
- modelAlias: this.provider.modelAlias,
103
- createdAt,
104
- expiresAt,
105
- status: "active",
106
- attemptCount: 0,
107
- activeRequests: 0,
108
- pendingAttempts: [],
109
- receipts: [],
110
- };
111
- await this.repository.create(record);
112
- await this.recordAudit("create", record, "success", {
113
- inputCommitment: record.inputCommitment,
114
- authorizationDigest: record.authorizationDigest,
115
- bindingDigest: record.clientBindingDigest,
58
+ return this.createWithAuthority(input, "direct");
59
+ }
60
+
61
+ /** 仅供固定 Workflow Artifact service 使用;路由不得暴露。 */
62
+ async createWorkflow(input: {
63
+ operationKey: string;
64
+ input: ModuleAiJsonValue;
65
+ clientBindingDigest: string;
66
+ hostSessionDigest: `sha256:${string}`;
67
+ actorId: string;
68
+ }) {
69
+ return this.createWithAuthority(input, "workflow-artifact");
70
+ }
71
+
72
+ private async createWithAuthority(
73
+ input: {
74
+ operationKey: string;
75
+ input: ModuleAiJsonValue;
76
+ clientBindingDigest: string;
77
+ hostSessionDigest: `sha256:${string}`;
78
+ actorId: string;
79
+ },
80
+ launchAuthority: "direct" | "workflow-artifact",
81
+ ) {
82
+ return createClientAgentSession({
83
+ request: input,
84
+ launchAuthority,
85
+ recovery: this.#recovery,
86
+ provider: this.provider,
87
+ admission: this.admission,
88
+ repository: this.repository,
89
+ trustedBaseUrl: this.trustedBaseUrl,
90
+ now: this.now,
91
+ maxTtlMs: this.maxTtlMs,
92
+ audit: async (record, metadata) => {
93
+ await this.recordAudit("create", record, "success", metadata);
94
+ },
116
95
  });
117
- return {
118
- clientRunId: record.clientRunId,
119
- sessionId: record.sessionId,
120
- trustedBaseUrl: new URL(
121
- `${encodeURIComponent(record.sessionId)}/v1/`,
122
- ensureTrailingSlash(this.trustedBaseUrl),
123
- ).toString(),
124
- modelAlias: record.modelAlias,
125
- limits: publicLimits(record),
126
- expiresAt: new Date(record.expiresAt).toISOString(),
127
- accessToken,
128
- capabilities: ["pageAgentClientRun"] as const,
129
- };
130
96
  }
131
97
 
132
98
  async complete(
@@ -152,6 +118,7 @@ export class GovernedClientAgentSessionService {
152
118
  caller,
153
119
  signal,
154
120
  current: await this.authorize(sessionId, caller, true),
121
+ attemptAuthority: this.attemptAuthority,
155
122
  repository: this.repository,
156
123
  admission: this.admission,
157
124
  provider: this.provider,
@@ -172,66 +139,49 @@ export class GovernedClientAgentSessionService {
172
139
  ) {
173
140
  await this.#recovery;
174
141
  const record = await this.authorize(sessionId, caller, false);
175
- const terminalDigest = digestJson({
176
- receiptIds: [...receiptIds],
177
- ...(clientResult === undefined ? {} : { result: clientResult }),
142
+ return finalizeClientSession({
143
+ record,
144
+ repository: this.repository,
145
+ admission: this.admission,
146
+ receiptIds,
147
+ clientResult,
148
+ audit: (completed, metadata) =>
149
+ this.recordAudit("finalize", completed, "success", metadata),
178
150
  });
179
- if (record.status === "completed") {
180
- if (record.terminalDigest === terminalDigest) return sessionView(record);
181
- throw conflict("会话已完成,终态不可改写");
182
- }
183
- if (record.status !== "active" || record.activeRequests) {
184
- throw conflict("会话当前不能 finalize");
185
- }
186
- const receipts = [...record.receipts].sort((a, b) => a.attempt - b.attempt);
151
+ }
152
+
153
+ async finalizeWorkflow(
154
+ sessionId: string,
155
+ caller: Omit<ClientSessionCaller, "token">,
156
+ receiptIds: readonly string[],
157
+ clientResult: ModuleAiJsonValue | undefined,
158
+ canonicalizeResult: (
159
+ proposedResult: ModuleAiJsonValue,
160
+ ) => Promise<ModuleAiJsonValue>,
161
+ ) {
162
+ await this.#recovery;
163
+ const record = await this.repository.find(sessionId);
187
164
  if (
188
- !receipts.length ||
189
- receiptIds.length !== receipts.length ||
190
- receipts.some((receipt, index) => receipt.receiptId !== receiptIds[index])
165
+ !record ||
166
+ record.actorId !== caller.actorId ||
167
+ record.clientBindingDigest !== caller.clientBindingDigest ||
168
+ record.hostSessionDigest !== caller.hostSessionDigest ||
169
+ (record.status !== "completed" &&
170
+ (record.status !== "active" || record.expiresAt <= this.now())) ||
171
+ !(await this.admission.reauthorize(record, caller.actorId))
191
172
  ) {
192
- throw invalid("Finalize 必须提交本 Run 的完整终末 receipt 链");
173
+ throw conflict("Workflow Client Agent Session 绑定已失效");
193
174
  }
194
- const terminal = receipts.at(-1)!;
195
- const projected = terminal.canonicalResult;
196
- const result = projected ?? clientResult;
197
- if (result === undefined) throw invalid("Finalize 缺少可公开结果");
198
- await this.admission.validateResult(record.operationKey, result);
199
- const finalResult: ClientSessionFinalResult = {
200
- clientRunId: record.clientRunId,
201
- status: "completed",
202
- result,
203
- attestation: {
204
- source:
205
- projected === undefined ? "client-projected" : "provider-observed",
206
- terminalReceiptId: terminal.receiptId,
207
- providerAttested: projected !== undefined,
208
- clientProjected: projected === undefined,
209
- },
210
- };
211
- const completed = await this.repository.transition(
212
- sessionId,
213
- "active",
214
- "completed",
215
- {
216
- terminalDigest,
217
- finalResult,
218
- },
219
- );
220
- if (!completed) {
221
- const concurrent = await this.repository.find(sessionId);
222
- if (
223
- concurrent?.status === "completed" &&
224
- concurrent.terminalDigest === terminalDigest
225
- ) {
226
- return sessionView(concurrent);
227
- }
228
- throw conflict("Finalize 与其它终态操作冲突");
229
- }
230
- await this.recordAudit("finalize", completed, "success", {
231
- terminalReceiptId: terminal.receiptId,
232
- clientProjected: projected === undefined,
175
+ return finalizeClientSession({
176
+ record,
177
+ repository: this.repository,
178
+ admission: this.admission,
179
+ receiptIds,
180
+ clientResult,
181
+ canonicalizeResult,
182
+ audit: (completed, metadata) =>
183
+ this.recordAudit("finalize", completed, "success", metadata),
233
184
  });
234
- return sessionView(completed);
235
185
  }
236
186
 
237
187
  async cancel(
@@ -310,6 +260,26 @@ export class GovernedClientAgentSessionService {
310
260
  });
311
261
  }
312
262
 
263
+ /** Host-internal revocation for a fenced Workflow claim; no browser token is accepted. */
264
+ async revokeSession(sessionId: string, cause: string) {
265
+ await this.#recovery;
266
+ const record = await this.repository.find(sessionId);
267
+ if (!record || record.status === "revoked") return;
268
+ if (record.status !== "active") return;
269
+ abortClientSession(this.#attemptControllers, sessionId);
270
+ const revoked = await this.repository.transition(
271
+ sessionId,
272
+ "active",
273
+ "revoked",
274
+ { terminalDigest: digestText(`revoke\0${cause.slice(0, 120)}`) },
275
+ );
276
+ if (revoked) {
277
+ await this.recordAudit("revoke", revoked, "success", {
278
+ cause: cause.slice(0, 120),
279
+ });
280
+ }
281
+ }
282
+
313
283
  private async authorize(
314
284
  sessionId: string,
315
285
  caller: ClientSessionCaller,
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { clientSessionTerminalDigest } from "./digest.js";
3
+
4
+ describe("Client Session terminal digest", () => {
5
+ test("canonical result key 顺序不改变 digest,receipt 顺序仍参与绑定", () => {
6
+ const left = clientSessionTerminalDigest(["receipt_1", "receipt_2"], {
7
+ answer: "done",
8
+ evidence: { b: 2, a: 1 },
9
+ });
10
+ const same = clientSessionTerminalDigest(["receipt_1", "receipt_2"], {
11
+ evidence: { a: 1, b: 2 },
12
+ answer: "done",
13
+ });
14
+ const reordered = clientSessionTerminalDigest(["receipt_2", "receipt_1"], {
15
+ answer: "done",
16
+ evidence: { a: 1, b: 2 },
17
+ });
18
+
19
+ expect(left).toBe(same);
20
+ expect(left).toMatch(/^sha256:[a-f0-9]{64}$/u);
21
+ expect(reordered).not.toBe(left);
22
+ });
23
+ });
@@ -0,0 +1,8 @@
1
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
2
+ import { DrizzleClientAgentWorkflowArtifactRepository } from "./drizzle-repository.js";
3
+
4
+ export function clientWorkflowArtifactActiveBlobReferences(
5
+ db: NodePgDatabase | undefined,
6
+ ) {
7
+ return db ? [new DrizzleClientAgentWorkflowArtifactRepository(db)] : [];
8
+ }
@@ -0,0 +1,60 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ModuleAiJsonValue } from "@southwind-ai/server-sdk";
3
+ import { artifactRef, type BlobStore } from "@southwind-ai/storage";
4
+ import { canonicalJson, digestJson } from "../client-session/digest.js";
5
+ import type {
6
+ ClientAgentWorkflowArtifactBinding,
7
+ ClientAgentWorkflowArtifactRecord,
8
+ } from "./contracts.js";
9
+ import { ClientAgentWorkflowArtifactError } from "./error.js";
10
+
11
+ export function clientWorkflowTerminalReceipt(
12
+ record: ClientAgentWorkflowArtifactRecord,
13
+ historyHeadDigest: string,
14
+ ) {
15
+ return `cawr_${digestJson({
16
+ workId: record.workId,
17
+ claimGeneration: record.claimGeneration,
18
+ historyHeadDigest,
19
+ }).slice("sha256:".length, 53)}`;
20
+ }
21
+
22
+ export async function storeClientWorkflowArtifact(input: {
23
+ blobs: BlobStore;
24
+ result: ModuleAiJsonValue;
25
+ binding: ClientAgentWorkflowArtifactBinding;
26
+ terminalReceiptDigest: `sha256:${string}`;
27
+ }) {
28
+ const bytes = new TextEncoder().encode(canonicalJson(input.result));
29
+ if (bytes.byteLength > input.binding.artifact.maxByteSize) {
30
+ throw new ClientAgentWorkflowArtifactError(
31
+ "ARTIFACT_TOO_LARGE",
32
+ "Client Agent Workflow 产物超过策略上限",
33
+ 413,
34
+ );
35
+ }
36
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
37
+ if (!input.blobs.putOnce) {
38
+ throw new ClientAgentWorkflowArtifactError(
39
+ "ARTIFACT_STORE_UNAVAILABLE",
40
+ "Client Agent Workflow 稳定产物存储未装配",
41
+ 503,
42
+ );
43
+ }
44
+ const stableRef = artifactRef(
45
+ `blob_${input.terminalReceiptDigest.slice("sha256:".length, 39)}`,
46
+ );
47
+ const stored = await input.blobs.putOnce({
48
+ ref: stableRef,
49
+ body: new Blob([bytes]).stream(),
50
+ expectedByteSize: bytes.byteLength,
51
+ expectedContentHash: { algorithm: "sha256", value: sha256 },
52
+ });
53
+ return {
54
+ artifactRef: stored.ref,
55
+ filename: input.binding.artifact.filename,
56
+ contentType: input.binding.artifact.contentType,
57
+ byteSize: stored.byteSize,
58
+ sha256,
59
+ };
60
+ }
@@ -0,0 +1,59 @@
1
+ import {
2
+ agentWorkflowNodeRuns,
3
+ agentWorkflowRuns,
4
+ agentWorkflowSideEffectCommands,
5
+ } from "@southwind-ai/database";
6
+ import { eq } from "drizzle-orm";
7
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
+ import type { ClientAgentWorkflowArtifactRecord } from "./contracts.js";
9
+
10
+ export async function clientWorkflowAuthorityCurrent(
11
+ db: NodePgDatabase,
12
+ record: ClientAgentWorkflowArtifactRecord,
13
+ now: number,
14
+ ) {
15
+ const [command] = await db
16
+ .select()
17
+ .from(agentWorkflowSideEffectCommands)
18
+ .where(eq(agentWorkflowSideEffectCommands.commandId, record.commandId))
19
+ .for("update")
20
+ .limit(1);
21
+ if (!command) return false;
22
+ const [run] = await db
23
+ .select()
24
+ .from(agentWorkflowRuns)
25
+ .where(eq(agentWorkflowRuns.runId, record.runId))
26
+ .for("update")
27
+ .limit(1);
28
+ if (!run) return false;
29
+ const [nodeRun] = await db
30
+ .select()
31
+ .from(agentWorkflowNodeRuns)
32
+ .where(eq(agentWorkflowNodeRuns.nodeRunId, record.nodeRunId))
33
+ .for("update")
34
+ .limit(1);
35
+ return Boolean(
36
+ nodeRun &&
37
+ command.status === "dispatched" &&
38
+ command.runId === record.runId &&
39
+ command.nodeRunId === record.nodeRunId &&
40
+ command.effectKind === record.effectKind &&
41
+ command.adapterKind === "durable-job" &&
42
+ command.generation === record.effectGeneration &&
43
+ command.requestDigest === record.requestDigest &&
44
+ command.fenceTokenDigest === record.effectFenceTokenDigest &&
45
+ command.authorizationSnapshotDigest ===
46
+ record.authorizationSnapshotDigest &&
47
+ command.runPolicyDigest === record.runPolicyDigest &&
48
+ ["accepted", "running", "waiting"].includes(run.status) &&
49
+ !run.cancellationRequestedAt &&
50
+ run.deadlineAt > now &&
51
+ run.actorId === record.actorId &&
52
+ run.scopeDigest === record.scopeDigest &&
53
+ run.authorizationSnapshotDigest === record.authorizationSnapshotDigest &&
54
+ run.executionPolicyDigest === record.runPolicyDigest &&
55
+ nodeRun.runId === record.runId &&
56
+ nodeRun.nodeType === "job" &&
57
+ ["running", "waiting"].includes(nodeRun.status),
58
+ );
59
+ }