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,103 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import type { ModuleAiJsonValue } from "@southwind-ai/server-sdk";
3
+ import type {
4
+ ClientAgentProviderPort,
5
+ ClientSessionAdmissionPort,
6
+ ClientSessionRecord,
7
+ } from "./contracts.js";
8
+ import { digestText, validDigest } from "./digest.js";
9
+ import { ClientAgentSessionError, clientSessionInvalid } from "./errors.js";
10
+ import { ensureTrailingSlash, publicLimits } from "./presentation.js";
11
+ import type { ClientSessionRepository } from "./repository.js";
12
+
13
+ export async function createClientAgentSession(input: {
14
+ readonly request: {
15
+ operationKey: string;
16
+ input: ModuleAiJsonValue;
17
+ clientBindingDigest: string;
18
+ hostSessionDigest: `sha256:${string}`;
19
+ actorId: string;
20
+ };
21
+ readonly launchAuthority: "direct" | "workflow-artifact";
22
+ readonly recovery: Promise<number>;
23
+ readonly provider: ClientAgentProviderPort;
24
+ readonly admission: ClientSessionAdmissionPort;
25
+ readonly repository: ClientSessionRepository;
26
+ readonly trustedBaseUrl: string;
27
+ readonly now: () => number;
28
+ readonly maxTtlMs: number;
29
+ readonly audit: (
30
+ record: ClientSessionRecord,
31
+ metadata: Readonly<Record<string, string>>,
32
+ ) => Promise<void>;
33
+ }) {
34
+ await input.recovery;
35
+ if (!(await input.provider.ready())) {
36
+ throw new ClientAgentSessionError(
37
+ "PROVIDER_FAILED",
38
+ "Client Agent Provider 尚未就绪",
39
+ 503,
40
+ );
41
+ }
42
+ const request = input.request;
43
+ if (
44
+ !validDigest(request.clientBindingDigest) ||
45
+ !validDigest(request.hostSessionDigest) ||
46
+ !request.actorId.trim()
47
+ ) {
48
+ throw clientSessionInvalid("Client Agent binding 或 Actor 无效");
49
+ }
50
+ const admission = await input.admission.admit({
51
+ operationKey: request.operationKey,
52
+ input: request.input,
53
+ actorId: request.actorId,
54
+ clientBindingDigest: request.clientBindingDigest,
55
+ launchAuthority: input.launchAuthority,
56
+ });
57
+ if (
58
+ admission.actorId !== request.actorId ||
59
+ admission.operationKey !== request.operationKey ||
60
+ admission.clientBindingDigest !== request.clientBindingDigest
61
+ ) {
62
+ throw new Error("Client Agent admission 返回了不一致的绑定");
63
+ }
64
+ const createdAt = input.now();
65
+ const expiresAt =
66
+ createdAt +
67
+ Math.min(admission.limits.maxDurationSeconds * 1_000, input.maxTtlMs);
68
+ const accessToken = `car_${randomBytes(32).toString("base64url")}`;
69
+ const record: ClientSessionRecord = {
70
+ ...admission,
71
+ sessionId: `cas_${randomUUID()}`,
72
+ clientRunId: `carun_${randomUUID()}`,
73
+ tokenHash: digestText(accessToken),
74
+ hostSessionDigest: request.hostSessionDigest,
75
+ modelAlias: input.provider.modelAlias,
76
+ createdAt,
77
+ expiresAt,
78
+ status: "active",
79
+ attemptCount: 0,
80
+ activeRequests: 0,
81
+ pendingAttempts: [],
82
+ receipts: [],
83
+ };
84
+ await input.repository.create(record);
85
+ await input.audit(record, {
86
+ inputCommitment: record.inputCommitment,
87
+ authorizationDigest: record.authorizationDigest,
88
+ bindingDigest: record.clientBindingDigest,
89
+ });
90
+ return {
91
+ clientRunId: record.clientRunId,
92
+ sessionId: record.sessionId,
93
+ trustedBaseUrl: new URL(
94
+ `${encodeURIComponent(record.sessionId)}/v1/`,
95
+ ensureTrailingSlash(input.trustedBaseUrl),
96
+ ).toString(),
97
+ modelAlias: record.modelAlias,
98
+ limits: publicLimits(record),
99
+ expiresAt: new Date(record.expiresAt).toISOString(),
100
+ accessToken,
101
+ capabilities: ["pageAgentClientRun"] as const,
102
+ };
103
+ }
@@ -5,6 +5,16 @@ export function digestJson(value: ModuleAiJsonValue): `sha256:${string}` {
5
5
  return digestText(canonicalJson(value));
6
6
  }
7
7
 
8
+ export function clientSessionTerminalDigest(
9
+ receiptIds: readonly string[],
10
+ clientResult?: ModuleAiJsonValue,
11
+ ): `sha256:${string}` {
12
+ return digestJson({
13
+ receiptIds: [...receiptIds],
14
+ ...(clientResult === undefined ? {} : { result: clientResult }),
15
+ });
16
+ }
17
+
8
18
  export function digestText(value: string): `sha256:${string}` {
9
19
  return `sha256:${createHash("sha256").update(value).digest("hex")}`;
10
20
  }
@@ -22,7 +32,7 @@ export function sameSecret(left: string, right: string): boolean {
22
32
  );
23
33
  }
24
34
 
25
- function canonicalJson(value: ModuleAiJsonValue): string {
35
+ export function canonicalJson(value: ModuleAiJsonValue): string {
26
36
  if (value === null || typeof value !== "object") {
27
37
  if (typeof value === "number" && !Number.isFinite(value)) {
28
38
  throw new TypeError("Client Agent JSON 不能包含非有限数值");
@@ -0,0 +1,150 @@
1
+ import {
2
+ clientAgentReceipts,
3
+ clientAgentSessions,
4
+ } from "@southwind-ai/database";
5
+ import type { ModuleAiJsonValue } from "@southwind-ai/server-sdk";
6
+ import { asc, eq } from "drizzle-orm";
7
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
+ import type {
9
+ ClientSessionAdmission,
10
+ ClientSessionFinalResult,
11
+ ClientSessionReceiptRecord,
12
+ ClientSessionRecord,
13
+ ClientSessionStatus,
14
+ } from "./contracts.js";
15
+
16
+ type SessionRow = typeof clientAgentSessions.$inferSelect;
17
+ type ReceiptRow = typeof clientAgentReceipts.$inferSelect;
18
+
19
+ export async function findClientSessionRecord(
20
+ db: NodePgDatabase,
21
+ sessionId: string,
22
+ ) {
23
+ const [row] = await db
24
+ .select()
25
+ .from(clientAgentSessions)
26
+ .where(eq(clientAgentSessions.sessionId, sessionId))
27
+ .limit(1);
28
+ return row ? hydrateClientSessionRecord(db, row) : undefined;
29
+ }
30
+
31
+ export async function hydrateClientSessionRecord(
32
+ db: NodePgDatabase,
33
+ row: SessionRow,
34
+ ) {
35
+ const receipts = await db
36
+ .select()
37
+ .from(clientAgentReceipts)
38
+ .where(eq(clientAgentReceipts.sessionId, row.sessionId))
39
+ .orderBy(asc(clientAgentReceipts.attempt));
40
+ return mapClientSessionRecord(row, receipts.map(mapReceipt));
41
+ }
42
+
43
+ export function clientSessionValues(record: ClientSessionRecord) {
44
+ const admission: ClientSessionAdmission = {
45
+ actorId: record.actorId,
46
+ operationKey: record.operationKey,
47
+ releaseId: record.releaseId,
48
+ inputCommitment: record.inputCommitment,
49
+ authorizationDigest: record.authorizationDigest,
50
+ clientBindingDigest: record.clientBindingDigest,
51
+ deploymentKey: record.deploymentKey,
52
+ providerDataPolicy: record.providerDataPolicy,
53
+ limits: record.limits,
54
+ };
55
+ return {
56
+ sessionId: record.sessionId,
57
+ clientRunId: record.clientRunId,
58
+ actorId: record.actorId,
59
+ operationKey: record.operationKey,
60
+ releaseId: record.releaseId,
61
+ tokenHash: record.tokenHash,
62
+ clientBindingDigest: record.clientBindingDigest,
63
+ hostSessionDigest: record.hostSessionDigest,
64
+ modelAlias: record.modelAlias,
65
+ status: record.status,
66
+ attemptCount: record.attemptCount,
67
+ activeRequests: record.activeRequests,
68
+ pendingAttempts: [...record.pendingAttempts],
69
+ admissionJson: admission as unknown as Record<string, unknown>,
70
+ terminalDigest: record.terminalDigest,
71
+ finalResultJson: record.finalResult as Record<string, unknown> | undefined,
72
+ createdAt: record.createdAt,
73
+ expiresAt: record.expiresAt,
74
+ };
75
+ }
76
+
77
+ export function mapClientSessionRecord(
78
+ row: SessionRow,
79
+ receipts: readonly ClientSessionReceiptRecord[],
80
+ ): ClientSessionRecord {
81
+ const admission = row.admissionJson as unknown as ClientSessionAdmission;
82
+ return {
83
+ ...admission,
84
+ sessionId: row.sessionId,
85
+ clientRunId: row.clientRunId,
86
+ actorId: row.actorId,
87
+ operationKey: row.operationKey,
88
+ releaseId: row.releaseId,
89
+ tokenHash: row.tokenHash as `sha256:${string}`,
90
+ clientBindingDigest: row.clientBindingDigest as `sha256:${string}`,
91
+ hostSessionDigest: row.hostSessionDigest as `sha256:${string}`,
92
+ modelAlias: row.modelAlias,
93
+ status: row.status as ClientSessionStatus,
94
+ attemptCount: row.attemptCount,
95
+ activeRequests: row.activeRequests,
96
+ pendingAttempts: row.pendingAttempts,
97
+ receipts,
98
+ createdAt: row.createdAt,
99
+ expiresAt: row.expiresAt,
100
+ ...(row.terminalDigest
101
+ ? { terminalDigest: row.terminalDigest as `sha256:${string}` }
102
+ : {}),
103
+ ...(row.finalResultJson
104
+ ? {
105
+ finalResult:
106
+ row.finalResultJson as unknown as ClientSessionFinalResult,
107
+ }
108
+ : {}),
109
+ };
110
+ }
111
+
112
+ export function clientSessionReceiptValues(
113
+ sessionId: string,
114
+ receipt: ClientSessionReceiptRecord,
115
+ ) {
116
+ return {
117
+ receiptId: receipt.receiptId,
118
+ sessionId,
119
+ clientRunId: receipt.clientRunId,
120
+ actorId: receipt.actorId,
121
+ operationKey: receipt.operationKey,
122
+ releaseId: receipt.releaseId,
123
+ clientBindingDigest: receipt.clientBindingDigest,
124
+ attempt: receipt.attempt,
125
+ requestDigest: receipt.requestDigest,
126
+ responseDigest: receipt.responseDigest,
127
+ deploymentDigest: receipt.deploymentDigest,
128
+ canonicalResultJson: receipt.canonicalResult,
129
+ issuedAt: receipt.issuedAt,
130
+ };
131
+ }
132
+
133
+ function mapReceipt(row: ReceiptRow): ClientSessionReceiptRecord {
134
+ return {
135
+ receiptId: row.receiptId,
136
+ clientRunId: row.clientRunId,
137
+ actorId: row.actorId,
138
+ operationKey: row.operationKey,
139
+ releaseId: row.releaseId,
140
+ clientBindingDigest: row.clientBindingDigest as `sha256:${string}`,
141
+ attempt: row.attempt,
142
+ requestDigest: row.requestDigest as `sha256:${string}`,
143
+ responseDigest: row.responseDigest as `sha256:${string}`,
144
+ deploymentDigest: row.deploymentDigest as `sha256:${string}`,
145
+ issuedAt: row.issuedAt,
146
+ ...(row.canonicalResultJson === null
147
+ ? {}
148
+ : { canonicalResult: row.canonicalResultJson as ModuleAiJsonValue }),
149
+ };
150
+ }
@@ -8,6 +8,7 @@ import type {
8
8
  ClientSessionRecord,
9
9
  } from "./contracts.js";
10
10
  import { DrizzleClientSessionRepository } from "./drizzle-repository.js";
11
+ import { ClientSessionAttemptAuthorityBinding } from "./attempt-authority.js";
11
12
 
12
13
  const databaseUrl = process.env.CLIENT_AGENT_TEST_DATABASE_URL;
13
14
  const describePostgres = databaseUrl ? describe : describe.skip;
@@ -38,7 +39,25 @@ describePostgres("PostgreSQL Client Agent Session repository", () => {
38
39
  });
39
40
 
40
41
  test("重启恢复 session/receipt,且并发预留与终态保持原子", async () => {
41
- const first = new DrizzleClientSessionRepository(drizzle(pool));
42
+ let transactionAuthorityChecks = 0;
43
+ const commitNow = 3_000;
44
+ const authority = new ClientSessionAttemptAuthorityBinding();
45
+ authority.bind({
46
+ async check() {
47
+ return "ordinary";
48
+ },
49
+ async checkTransaction(transaction, _facts, now) {
50
+ expect(transaction).toBeDefined();
51
+ expect(now).toBe(commitNow);
52
+ transactionAuthorityChecks += 1;
53
+ return "ordinary";
54
+ },
55
+ });
56
+ const first = new DrizzleClientSessionRepository(
57
+ drizzle(pool),
58
+ authority,
59
+ () => commitNow,
60
+ );
42
61
  await first.create(session());
43
62
  const [accepted, denied] = await Promise.all([
44
63
  first.reserveAttempt(caller()),
@@ -47,7 +66,11 @@ describePostgres("PostgreSQL Client Agent Session repository", () => {
47
66
  expect([accepted, denied].filter(Boolean)).toHaveLength(1);
48
67
  expect([accepted, denied].filter((value) => !value)).toHaveLength(1);
49
68
 
50
- const restarted = new DrizzleClientSessionRepository(drizzle(pool));
69
+ const restarted = new DrizzleClientSessionRepository(
70
+ drizzle(pool),
71
+ authority,
72
+ () => commitNow,
73
+ );
51
74
  expect(await restarted.find("cas_1")).toMatchObject({
52
75
  clientRunId: "carun_1",
53
76
  attemptCount: 1,
@@ -75,8 +98,12 @@ describePostgres("PostgreSQL Client Agent Session repository", () => {
75
98
  });
76
99
 
77
100
  const completed = await restarted.completeAttempt("cas_1", receipt());
101
+ expect(transactionAuthorityChecks).toBe(1);
78
102
  expect(completed?.receipts).toHaveLength(1);
79
- const afterReceipt = new DrizzleClientSessionRepository(drizzle(pool));
103
+ const afterReceipt = new DrizzleClientSessionRepository(
104
+ drizzle(pool),
105
+ authority,
106
+ );
80
107
  expect((await afterReceipt.find("cas_1"))?.receipts[0]).toEqual(receipt());
81
108
 
82
109
  const terminal = await afterReceipt.transition(
@@ -2,24 +2,33 @@ import {
2
2
  clientAgentReceipts,
3
3
  clientAgentSessions,
4
4
  } from "@southwind-ai/database";
5
- import type { ModuleAiJsonValue } from "@southwind-ai/server-sdk";
6
- import { and, asc, eq, sql } from "drizzle-orm";
5
+ import { and, eq, sql } from "drizzle-orm";
7
6
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
7
  import type {
9
- ClientSessionAdmission,
10
- ClientSessionFinalResult,
11
8
  ClientSessionReceiptRecord,
12
9
  ClientSessionRecord,
13
10
  ClientSessionStatus,
14
11
  } from "./contracts.js";
15
12
  import { receiptMatches, type ClientSessionRepository } from "./repository.js";
16
13
  import { sameSecret } from "./digest.js";
17
-
18
- type SessionRow = typeof clientAgentSessions.$inferSelect;
19
- type ReceiptRow = typeof clientAgentReceipts.$inferSelect;
14
+ import {
15
+ clientSessionAttemptAuthorityFacts,
16
+ type ClientSessionAttemptAuthorityBinding,
17
+ } from "./attempt-authority.js";
18
+ import {
19
+ clientSessionReceiptValues as receiptValues,
20
+ clientSessionValues as sessionValues,
21
+ findClientSessionRecord as findRecord,
22
+ hydrateClientSessionRecord as hydrateRecord,
23
+ mapClientSessionRecord as mapRecord,
24
+ } from "./drizzle-records.js";
20
25
 
21
26
  export class DrizzleClientSessionRepository implements ClientSessionRepository {
22
- constructor(private readonly db: NodePgDatabase) {}
27
+ constructor(
28
+ private readonly db: NodePgDatabase,
29
+ private readonly attemptAuthority?: ClientSessionAttemptAuthorityBinding,
30
+ private readonly now: () => number = Date.now,
31
+ ) {}
23
32
 
24
33
  async recoverInterrupted(now: number): Promise<number> {
25
34
  return this.db.transaction(async (transaction) => {
@@ -101,6 +110,12 @@ export class DrizzleClientSessionRepository implements ClientSessionRepository {
101
110
  ) {
102
111
  return undefined;
103
112
  }
113
+ const authority = await this.attemptAuthority?.checkTransaction(
114
+ db,
115
+ clientSessionAttemptAuthorityFacts(record),
116
+ this.now(),
117
+ );
118
+ if (authority === "revoked") return undefined;
104
119
  await db
105
120
  .insert(clientAgentReceipts)
106
121
  .values(receiptValues(sessionId, receipt));
@@ -219,127 +234,3 @@ export class DrizzleClientSessionRepository implements ClientSessionRepository {
219
234
  });
220
235
  }
221
236
  }
222
-
223
- async function findRecord(db: NodePgDatabase, sessionId: string) {
224
- const [row] = await db
225
- .select()
226
- .from(clientAgentSessions)
227
- .where(eq(clientAgentSessions.sessionId, sessionId))
228
- .limit(1);
229
- return row ? hydrateRecord(db, row) : undefined;
230
- }
231
-
232
- async function hydrateRecord(db: NodePgDatabase, row: SessionRow) {
233
- const receipts = await db
234
- .select()
235
- .from(clientAgentReceipts)
236
- .where(eq(clientAgentReceipts.sessionId, row.sessionId))
237
- .orderBy(asc(clientAgentReceipts.attempt));
238
- return mapRecord(row, receipts.map(mapReceipt));
239
- }
240
-
241
- function sessionValues(record: ClientSessionRecord) {
242
- const admission: ClientSessionAdmission = {
243
- actorId: record.actorId,
244
- operationKey: record.operationKey,
245
- releaseId: record.releaseId,
246
- inputCommitment: record.inputCommitment,
247
- authorizationDigest: record.authorizationDigest,
248
- clientBindingDigest: record.clientBindingDigest,
249
- deploymentKey: record.deploymentKey,
250
- providerDataPolicy: record.providerDataPolicy,
251
- limits: record.limits,
252
- };
253
- return {
254
- sessionId: record.sessionId,
255
- clientRunId: record.clientRunId,
256
- actorId: record.actorId,
257
- operationKey: record.operationKey,
258
- releaseId: record.releaseId,
259
- tokenHash: record.tokenHash,
260
- clientBindingDigest: record.clientBindingDigest,
261
- hostSessionDigest: record.hostSessionDigest,
262
- modelAlias: record.modelAlias,
263
- status: record.status,
264
- attemptCount: record.attemptCount,
265
- activeRequests: record.activeRequests,
266
- pendingAttempts: [...record.pendingAttempts],
267
- admissionJson: admission as unknown as Record<string, unknown>,
268
- terminalDigest: record.terminalDigest,
269
- finalResultJson: record.finalResult as Record<string, unknown> | undefined,
270
- createdAt: record.createdAt,
271
- expiresAt: record.expiresAt,
272
- };
273
- }
274
-
275
- function mapRecord(
276
- row: SessionRow,
277
- receipts: readonly ClientSessionReceiptRecord[],
278
- ): ClientSessionRecord {
279
- const admission = row.admissionJson as unknown as ClientSessionAdmission;
280
- return {
281
- ...admission,
282
- sessionId: row.sessionId,
283
- clientRunId: row.clientRunId,
284
- actorId: row.actorId,
285
- operationKey: row.operationKey,
286
- releaseId: row.releaseId,
287
- tokenHash: row.tokenHash as `sha256:${string}`,
288
- clientBindingDigest: row.clientBindingDigest as `sha256:${string}`,
289
- hostSessionDigest: row.hostSessionDigest as `sha256:${string}`,
290
- modelAlias: row.modelAlias,
291
- status: row.status as ClientSessionStatus,
292
- attemptCount: row.attemptCount,
293
- activeRequests: row.activeRequests,
294
- pendingAttempts: row.pendingAttempts,
295
- receipts,
296
- createdAt: row.createdAt,
297
- expiresAt: row.expiresAt,
298
- ...(row.terminalDigest
299
- ? { terminalDigest: row.terminalDigest as `sha256:${string}` }
300
- : {}),
301
- ...(row.finalResultJson
302
- ? {
303
- finalResult:
304
- row.finalResultJson as unknown as ClientSessionFinalResult,
305
- }
306
- : {}),
307
- };
308
- }
309
-
310
- function receiptValues(sessionId: string, receipt: ClientSessionReceiptRecord) {
311
- return {
312
- receiptId: receipt.receiptId,
313
- sessionId,
314
- clientRunId: receipt.clientRunId,
315
- actorId: receipt.actorId,
316
- operationKey: receipt.operationKey,
317
- releaseId: receipt.releaseId,
318
- clientBindingDigest: receipt.clientBindingDigest,
319
- attempt: receipt.attempt,
320
- requestDigest: receipt.requestDigest,
321
- responseDigest: receipt.responseDigest,
322
- deploymentDigest: receipt.deploymentDigest,
323
- canonicalResultJson: receipt.canonicalResult,
324
- issuedAt: receipt.issuedAt,
325
- };
326
- }
327
-
328
- function mapReceipt(row: ReceiptRow): ClientSessionReceiptRecord {
329
- return {
330
- receiptId: row.receiptId,
331
- clientRunId: row.clientRunId,
332
- actorId: row.actorId,
333
- operationKey: row.operationKey,
334
- releaseId: row.releaseId,
335
- clientBindingDigest: row.clientBindingDigest as `sha256:${string}`,
336
- attempt: row.attempt,
337
- requestDigest: row.requestDigest as `sha256:${string}`,
338
- responseDigest: row.responseDigest as `sha256:${string}`,
339
- deploymentDigest: row.deploymentDigest as `sha256:${string}`,
340
- issuedAt: row.issuedAt,
341
- ...(row.canonicalResultJson === null
342
- ? {}
343
- : { canonicalResult: row.canonicalResultJson as ModuleAiJsonValue }),
344
- };
345
- }
@@ -0,0 +1,99 @@
1
+ import type { ModuleAiJsonValue } from "@southwind-ai/server-sdk";
2
+ import type {
3
+ ClientSessionAdmissionPort,
4
+ ClientSessionFinalResult,
5
+ ClientSessionRecord,
6
+ } from "./contracts.js";
7
+ import { clientSessionTerminalDigest } from "./digest.js";
8
+ import {
9
+ clientSessionConflict as conflict,
10
+ clientSessionInvalid as invalid,
11
+ } from "./errors.js";
12
+ import { sessionView } from "./presentation.js";
13
+ import type { ClientSessionRepository } from "./repository.js";
14
+
15
+ export async function finalizeClientSession(input: {
16
+ record: ClientSessionRecord;
17
+ repository: ClientSessionRepository;
18
+ admission: ClientSessionAdmissionPort;
19
+ receiptIds: readonly string[];
20
+ clientResult?: ModuleAiJsonValue;
21
+ canonicalizeResult?: (
22
+ proposedResult: ModuleAiJsonValue,
23
+ ) => Promise<ModuleAiJsonValue>;
24
+ audit(
25
+ record: ClientSessionRecord,
26
+ metadata: Readonly<Record<string, string | number | boolean>>,
27
+ ): Promise<void> | void;
28
+ }) {
29
+ const terminalDigest = clientSessionTerminalDigest(
30
+ input.receiptIds,
31
+ input.clientResult,
32
+ );
33
+ if (input.record.status === "completed") {
34
+ if (input.record.terminalDigest === terminalDigest) {
35
+ return sessionView(input.record);
36
+ }
37
+ throw conflict("会话已完成,终态不可改写");
38
+ }
39
+ if (input.record.status !== "active" || input.record.activeRequests) {
40
+ throw conflict("会话当前不能 finalize");
41
+ }
42
+ const receipts = [...input.record.receipts].sort(
43
+ (left, right) => left.attempt - right.attempt,
44
+ );
45
+ if (
46
+ !receipts.length ||
47
+ input.receiptIds.length !== receipts.length ||
48
+ receipts.some(
49
+ (receipt, index) => receipt.receiptId !== input.receiptIds[index],
50
+ )
51
+ ) {
52
+ throw invalid("Finalize 必须提交本 Run 的完整终末 receipt 链");
53
+ }
54
+ const terminal = receipts.at(-1)!;
55
+ const projected = terminal.canonicalResult;
56
+ const proposedResult = projected ?? input.clientResult;
57
+ if (proposedResult === undefined) throw invalid("Finalize 缺少可公开结果");
58
+ await input.admission.validateResult(
59
+ input.record.operationKey,
60
+ proposedResult,
61
+ );
62
+ const result = input.canonicalizeResult
63
+ ? await input.canonicalizeResult(proposedResult)
64
+ : proposedResult;
65
+ await input.admission.validateResult(input.record.operationKey, result);
66
+ const finalResult: ClientSessionFinalResult = {
67
+ clientRunId: input.record.clientRunId,
68
+ status: "completed",
69
+ result,
70
+ attestation: {
71
+ source:
72
+ projected === undefined ? "client-projected" : "provider-observed",
73
+ terminalReceiptId: terminal.receiptId,
74
+ providerAttested: projected !== undefined,
75
+ clientProjected: projected === undefined,
76
+ },
77
+ };
78
+ const completed = await input.repository.transition(
79
+ input.record.sessionId,
80
+ "active",
81
+ "completed",
82
+ { terminalDigest, finalResult },
83
+ );
84
+ if (!completed) {
85
+ const concurrent = await input.repository.find(input.record.sessionId);
86
+ if (
87
+ concurrent?.status === "completed" &&
88
+ concurrent.terminalDigest === terminalDigest
89
+ ) {
90
+ return sessionView(concurrent);
91
+ }
92
+ throw conflict("Finalize 与其它终态操作冲突");
93
+ }
94
+ await input.audit(completed, {
95
+ terminalReceiptId: terminal.receiptId,
96
+ clientProjected: projected === undefined,
97
+ });
98
+ return sessionView(completed);
99
+ }
@@ -39,11 +39,22 @@ export function createClientAgentHostAuthRouteApp(input: {
39
39
  };
40
40
  if (!(await input.adapter.ready(context.request.signal)))
41
41
  throw unavailable();
42
- const principal = await input.adapter.authenticate({
43
- token: hostToken(context.request, path),
44
- purpose: isCompletion(path) ? "completion" : "control",
45
- signal: context.request.signal,
46
- });
42
+ const purpose = isCompletion(path) ? "completion" : "control";
43
+ const token = hostToken(context.request);
44
+ const principal = token
45
+ ? await input.adapter.authenticate({
46
+ token,
47
+ purpose,
48
+ signal: context.request.signal,
49
+ })
50
+ : context.guardContext.actor.type !== "anonymous"
51
+ ? await input.adapter.authenticateCurrentSession?.({
52
+ request: context.request,
53
+ actorId: context.guardContext.actor.id,
54
+ purpose,
55
+ signal: context.request.signal,
56
+ })
57
+ : undefined;
47
58
  const actor = principal
48
59
  ? await input.resolveActor(principal.actorId)
49
60
  : undefined;
@@ -84,12 +95,13 @@ export function createClientAgentHostAuthRouteApp(input: {
84
95
  };
85
96
  }
86
97
 
87
- function hostToken(request: Request, path: string): string {
88
- const header = path.includes(":sessionId")
89
- ? request.headers.get("x-windy-host-authorization")
90
- : request.headers.get("authorization");
98
+ function hostToken(request: Request): string | undefined {
99
+ const header =
100
+ request.headers.get("x-windy-host-authorization") ??
101
+ request.headers.get("authorization");
91
102
  const token = header?.match(/^Bearer\s+(.+)$/iu)?.[1];
92
- if (!token?.startsWith("bax_")) throw hidden();
103
+ if (!token || token.startsWith("car_")) return undefined;
104
+ if (!token.startsWith("bax_")) throw hidden();
93
105
  return token;
94
106
  }
95
107