create-windy 0.6.0 → 0.6.1

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 (57) hide show
  1. package/dist/cli.js +82 -2
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/embedding-server/src/southwind_embedding_server/app.py +27 -20
  5. package/template/apps/embedding-server/src/southwind_embedding_server/config.py +111 -4
  6. package/template/apps/embedding-server/src/southwind_embedding_server/contracts.py +13 -0
  7. package/template/apps/embedding-server/src/southwind_embedding_server/pin_cli.py +62 -0
  8. package/template/apps/embedding-server/src/southwind_embedding_server/readiness.py +259 -0
  9. package/template/apps/embedding-server/src/southwind_embedding_server/readiness_contracts.py +221 -0
  10. package/template/apps/embedding-server/src/southwind_embedding_server/readiness_http.py +91 -0
  11. package/template/apps/embedding-server/src/southwind_embedding_server/run_service.py +23 -3
  12. package/template/apps/embedding-server/tests/test_config.py +13 -0
  13. package/template/apps/embedding-server/tests/test_readiness.py +202 -0
  14. package/template/apps/embedding-server/tests/test_run_http.py +76 -4
  15. package/template/apps/embedding-server/tests/test_run_service.py +3 -1
  16. package/template/apps/server/src/agent/drizzle-screenshot-intake-repository.integration.test.ts +3 -0
  17. package/template/apps/server/src/agent/run-facade.ts +2 -1
  18. package/template/apps/server/src/agent/run-route-screenshot-context.test.ts +13 -0
  19. package/template/apps/server/src/agent/runtime-bootstrap.ts +2 -0
  20. package/template/apps/server/src/agent/screenshot-context-admission.test.ts +249 -0
  21. package/template/apps/server/src/agent/screenshot-context-attestation.ts +60 -0
  22. package/template/apps/server/src/agent/screenshot-intake-bootstrap.ts +2 -4
  23. package/template/apps/server/src/agent/screenshot-intake-test-support.ts +4 -0
  24. package/template/apps/server/src/agent/screenshot-intake-v2-validation.ts +24 -0
  25. package/template/apps/server/src/agent/screenshot-intake-v2.test.ts +7 -0
  26. package/template/apps/server/src/agent/screenshot-intake-v2.ts +8 -10
  27. package/template/apps/server/src/agent/screenshot-run-binding.ts +17 -2
  28. package/template/apps/server/src/agent/vision-readiness-pin.ts +6 -48
  29. package/template/apps/server/src/ai-runtime-readiness/deployment-pin.ts +69 -0
  30. package/template/apps/server/src/embedding/contracts.ts +6 -1
  31. package/template/apps/server/src/embedding/facade-readiness.test.ts +307 -0
  32. package/template/apps/server/src/embedding/facade.ts +8 -6
  33. package/template/apps/server/src/embedding/readiness-admission.ts +26 -0
  34. package/template/apps/server/src/embedding/remote-runtime.test.ts +85 -0
  35. package/template/apps/server/src/embedding/remote-runtime.ts +21 -0
  36. package/template/apps/server/src/embedding/run-fingerprint.test.ts +26 -0
  37. package/template/apps/server/src/embedding/run-fingerprint.ts +11 -2
  38. package/template/apps/server/src/module-host/ai-operation-port.test.ts +72 -0
  39. package/template/apps/server/src/module-host/ai-operation-port.ts +43 -0
  40. package/template/apps/server/src/module-host/ai-operation-speech-normalize.test.ts +186 -0
  41. package/template/docker-compose.yml +11 -0
  42. package/template/docs/architecture/ai-runtime-readiness.md +6 -0
  43. package/template/docs/platform/vision-ai-runtime.md +22 -1
  44. package/template/packages/config/package.json +1 -1
  45. package/template/packages/database/package.json +1 -1
  46. package/template/packages/jobs/package.json +1 -1
  47. package/template/packages/modules/index.ts +1 -0
  48. package/template/packages/modules/package.json +1 -1
  49. package/template/packages/modules/src/ai-operation-composition.ts +2 -0
  50. package/template/packages/modules/src/ai-screenshot-context.test.ts +72 -0
  51. package/template/packages/modules/src/ai-screenshot-context.ts +50 -0
  52. package/template/packages/modules/src/manifest.ts +8 -0
  53. package/template/packages/server-sdk/package.json +1 -1
  54. package/template/packages/shared/index.ts +3 -0
  55. package/template/packages/shared/package.json +1 -1
  56. package/template/packages/shared/src/screenshot-context-digest.test.ts +46 -0
  57. package/template/packages/shared/src/screenshot-context-digest.ts +72 -0
@@ -0,0 +1,307 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ModuleManifest } from "@southwind-ai/modules";
3
+ import { featureKey } from "@southwind-ai/shared";
4
+ import type {
5
+ AiRuntimeDeploymentV1,
6
+ ModuleAiJsonValue,
7
+ ModuleAiOperationReadinessV1,
8
+ } from "@southwind-ai/server-sdk";
9
+ import { buildScopeContext } from "../data-access/context.js";
10
+ import { createRequestGuardContext } from "../guards.js";
11
+ import { initializeModuleHosts } from "../module-host.js";
12
+ import { createServerRuntime } from "../runtime.js";
13
+ import type { EmbeddingRuntimeOperation } from "./contracts.js";
14
+ import { EmbeddingFacade } from "./facade.js";
15
+
16
+ const operationKey = "analysis.embedding.image";
17
+ const deployment: AiRuntimeDeploymentV1 = {
18
+ key: "embedding.primary",
19
+ identity: "windy-deployment-v1_0f26eb34-f384-4ec9-98f3-caf8d582291f",
20
+ releaseDigest: `sha256:${"a".repeat(64)}`,
21
+ offersDigest: `sha256:${"b".repeat(64)}`,
22
+ };
23
+
24
+ describe("EmbeddingFacade readiness admission", () => {
25
+ test("unavailable 与 probe 异常在业务 prepare、Artifact、Run 前稳定失败关闭", async () => {
26
+ for (const inspect of [
27
+ async () => unavailableProjection(),
28
+ async () => {
29
+ throw new Error("provider secret");
30
+ },
31
+ ]) {
32
+ const fixture = await createFixture(true, inspect);
33
+
34
+ await expect(fixture.start()).rejects.toMatchObject({
35
+ code: "RUNTIME_UNAVAILABLE",
36
+ message: "Embedding Runtime 当前不可用",
37
+ status: 503,
38
+ });
39
+ expect(fixture.calls).toEqual({
40
+ readiness: 1,
41
+ prepare: 0,
42
+ artifact: 0,
43
+ run: 0,
44
+ remote: 0,
45
+ });
46
+ }
47
+ });
48
+
49
+ test("ready projection 固定 deployment pin 后才创建 Artifact 与 Run", async () => {
50
+ const fixture = await createFixture(true, async () => readyProjection());
51
+
52
+ await expect(fixture.start()).resolves.toMatchObject({
53
+ runId: "embedding_server_bound",
54
+ status: "accepted",
55
+ });
56
+ expect(fixture.calls).toEqual({
57
+ readiness: 1,
58
+ prepare: 1,
59
+ artifact: 1,
60
+ run: 1,
61
+ remote: 1,
62
+ });
63
+ expect(fixture.request?.readinessPin).toEqual(deployment);
64
+ });
65
+
66
+ test("未声明 readiness 的旧 Operation 保留兼容路径", async () => {
67
+ const fixture = await createFixture(false, async () => {
68
+ throw new Error("legacy operation must not inspect V1");
69
+ });
70
+
71
+ await expect(fixture.start()).resolves.toMatchObject({
72
+ runId: "embedding_server_bound",
73
+ });
74
+ expect(fixture.calls.readiness).toBe(0);
75
+ expect(fixture.request?.readinessPin).toBeUndefined();
76
+ });
77
+ });
78
+
79
+ async function createFixture(
80
+ withReadiness: boolean,
81
+ inspect: () => Promise<ModuleAiOperationReadinessV1>,
82
+ ) {
83
+ const module = embeddingModule(withReadiness);
84
+ const calls = { readiness: 0, prepare: 0, artifact: 0, run: 0, remote: 0 };
85
+ let request: EmbeddingRuntimeOperation | undefined;
86
+ const hosts = await initializeModuleHosts({
87
+ modules: [module],
88
+ installations: [
89
+ {
90
+ manifest: module,
91
+ setup(host) {
92
+ host.registerAiOperationHandlers([
93
+ {
94
+ operationKey,
95
+ prepareEmbedding: (input: ModuleAiJsonValue) => {
96
+ calls.prepare += 1;
97
+ return { options: input };
98
+ },
99
+ },
100
+ ]);
101
+ },
102
+ },
103
+ ],
104
+ });
105
+ const facade = new EmbeddingFacade(
106
+ [module],
107
+ hosts,
108
+ createServerRuntime({}, { featureCatalog: module.features }),
109
+ {
110
+ async start(value: EmbeddingRuntimeOperation) {
111
+ calls.remote += 1;
112
+ request = value;
113
+ return { runId: "embedding_server_bound" };
114
+ },
115
+ } as never,
116
+ {
117
+ async rotate() {
118
+ calls.artifact += 1;
119
+ return artifact("rotated");
120
+ },
121
+ } as never,
122
+ {
123
+ async issue() {
124
+ calls.run += 1;
125
+ return { runId: "embedding_server_bound" };
126
+ },
127
+ async bind() {},
128
+ } as never,
129
+ {
130
+ async inspect() {
131
+ calls.readiness += 1;
132
+ return inspect();
133
+ },
134
+ },
135
+ );
136
+ return {
137
+ calls,
138
+ get request() {
139
+ return request;
140
+ },
141
+ start: () =>
142
+ facade.start(
143
+ operationKey,
144
+ [{ id: "image", source: artifact("submitted") }],
145
+ {},
146
+ "embedding-1",
147
+ guardContext(),
148
+ AbortSignal.timeout(1_000),
149
+ ),
150
+ };
151
+ }
152
+
153
+ function embeddingModule(withReadiness: boolean): ModuleManifest {
154
+ return {
155
+ name: "analysis",
156
+ version: "0.1.0",
157
+ label: "分析",
158
+ description: "分析",
159
+ menus: [],
160
+ adminRoutes: [],
161
+ permissions: [
162
+ {
163
+ key: "analysis.embedding.execute",
164
+ label: "向量化",
165
+ module: "analysis",
166
+ moduleLabel: "分析",
167
+ resource: "embedding",
168
+ resourceLabel: "向量化",
169
+ action: "manage",
170
+ },
171
+ ],
172
+ apiPermissions: [],
173
+ features: [
174
+ {
175
+ key: featureKey("analysis.embedding"),
176
+ label: "向量化",
177
+ module: "analysis",
178
+ visible: "visible",
179
+ enabled: true,
180
+ stage: "stable",
181
+ },
182
+ ],
183
+ migrations: [],
184
+ schemaExports: [],
185
+ jsonSchemas: [
186
+ { key: "analysis.embedding.input.v1", schema: { type: "object" } },
187
+ { key: "analysis.embedding.output.v1", schema: { type: "object" } },
188
+ ],
189
+ tasks: [],
190
+ tools: [],
191
+ aiOperations: [
192
+ {
193
+ key: operationKey,
194
+ kind: "embedding",
195
+ featureKey: "analysis.embedding",
196
+ permissionKey: "analysis.embedding.execute",
197
+ auditAction: "analysis.embedding.execute",
198
+ inputSchemaKey: "analysis.embedding.input.v1",
199
+ outputSchemaKey: "analysis.embedding.output.v1",
200
+ inputModalities: ["image"],
201
+ ...(withReadiness
202
+ ? {
203
+ readiness: {
204
+ contractVersion: 1 as const,
205
+ variant: "image",
206
+ inputModalities: ["image"] as const,
207
+ outputModalities: ["embedding"] as const,
208
+ expectedDeployment: deployment,
209
+ minConcurrentRuns: 1,
210
+ minQueueDepth: 0,
211
+ },
212
+ }
213
+ : {}),
214
+ requiredCapabilities: ["embedding.image"],
215
+ allowedToolKeys: [],
216
+ dataClassification: "sensitive",
217
+ execution: "background",
218
+ providerEgress: { mode: "forbidden" },
219
+ limits: {
220
+ maxDurationSeconds: 60,
221
+ maxInputBytes: 4_096,
222
+ maxOutputBytes: 4_096,
223
+ maxUnits: 1,
224
+ maxArtifacts: 1,
225
+ maxPixelsPerArtifact: 1_000,
226
+ },
227
+ },
228
+ ],
229
+ providers: [],
230
+ auditActions: ["analysis.embedding.execute"],
231
+ };
232
+ }
233
+
234
+ function guardContext() {
235
+ return createRequestGuardContext(new Request("http://localhost"), {
236
+ resolveActor: () => ({
237
+ id: "actor-1",
238
+ type: "user",
239
+ name: "测试用户",
240
+ roleCodes: ["analyst"],
241
+ permissionKeys: ["analysis.embedding.execute"],
242
+ scopeContext: buildScopeContext("actor-1", undefined, [
243
+ {
244
+ id: "analyst",
245
+ code: "analyst",
246
+ dataScope: "self",
247
+ departmentIds: [],
248
+ },
249
+ ]),
250
+ }),
251
+ resolveLicense: () => ({ status: "valid" }),
252
+ });
253
+ }
254
+
255
+ function artifact(value: string) {
256
+ return {
257
+ value,
258
+ mediaType: "image/png",
259
+ byteSize: 128,
260
+ sha256: "c".repeat(64),
261
+ expiresAt: "2030-01-01T00:00:00.000Z",
262
+ };
263
+ }
264
+
265
+ function readyProjection(): ModuleAiOperationReadinessV1 {
266
+ return {
267
+ schemaVersion: 1,
268
+ operationKey,
269
+ runtimeKind: "embedding",
270
+ observedAt: new Date().toISOString(),
271
+ status: "ready",
272
+ validUntil: new Date(Date.now() + 60_000).toISOString(),
273
+ deployment,
274
+ offers: [
275
+ {
276
+ variant: "image",
277
+ capabilities: ["embedding.image"],
278
+ inputModalities: ["image"],
279
+ outputModalities: ["embedding"],
280
+ limits: {
281
+ maxDurationSeconds: 60,
282
+ maxInputBytes: 4_096,
283
+ maxOutputBytes: 4_096,
284
+ maxUnits: 1,
285
+ maxArtifacts: 1,
286
+ maxPixelsPerArtifact: 1_000,
287
+ maxConcurrentRuns: 1,
288
+ maxQueueDepth: 0,
289
+ },
290
+ },
291
+ ],
292
+ unavailableReasons: [],
293
+ };
294
+ }
295
+
296
+ function unavailableProjection(): ModuleAiOperationReadinessV1 {
297
+ return {
298
+ schemaVersion: 1,
299
+ operationKey,
300
+ runtimeKind: "embedding",
301
+ observedAt: new Date().toISOString(),
302
+ status: "unavailable",
303
+ deployment: null,
304
+ offers: [],
305
+ unavailableReasons: [{ code: "DOWNSTREAM_PROBE_FAILED", retryable: true }],
306
+ };
307
+ }
@@ -31,10 +31,10 @@ import {
31
31
  embeddingErrorStatus,
32
32
  type RegisteredEmbeddingOperation,
33
33
  } from "./facade-types.js";
34
+ import { type ReadinessPort, requireReadiness } from "./readiness-admission.js";
34
35
 
35
36
  export class EmbeddingFacade {
36
37
  readonly #operations: Map<string, RegisteredEmbeddingOperation>;
37
-
38
38
  constructor(
39
39
  modules: readonly ModuleManifest[],
40
40
  hosts: InitializedModuleHosts,
@@ -42,6 +42,7 @@ export class EmbeddingFacade {
42
42
  private readonly remote: EmbeddingRuntimePort,
43
43
  private readonly artifacts: AiArtifactRuntime,
44
44
  private readonly runs: EmbeddingRunAuthorizations,
45
+ private readonly readiness: ReadinessPort,
45
46
  ) {
46
47
  const handlers = new Map(
47
48
  hosts.aiOperationHandlers.map(({ registration, moduleName }) => [
@@ -68,7 +69,6 @@ export class EmbeddingFacade {
68
69
  ),
69
70
  );
70
71
  }
71
-
72
72
  async embed(
73
73
  operationKey: string,
74
74
  items: readonly EmbeddingFacadeItem[],
@@ -113,7 +113,6 @@ export class EmbeddingFacade {
113
113
  throw error;
114
114
  }
115
115
  }
116
-
117
116
  async start(
118
117
  operationKey: string,
119
118
  items: readonly EmbeddingFacadeItem[],
@@ -133,6 +132,7 @@ export class EmbeddingFacade {
133
132
  operationKey,
134
133
  prepared.items,
135
134
  prepared.request.options,
135
+ prepared.request.readinessPin,
136
136
  );
137
137
  const binding = await this.runs.issue({
138
138
  operationKey,
@@ -176,7 +176,6 @@ export class EmbeddingFacade {
176
176
  throw error;
177
177
  }
178
178
  }
179
-
180
179
  async authorizeRun(runId: string, context: RequestGuardContext) {
181
180
  const binding = await this.runs.require(runId, context);
182
181
  const operation = this.#operations.get(binding.operationKey);
@@ -226,7 +225,6 @@ export class EmbeddingFacade {
226
225
  await this.remote.cancel(runId, undefined, signal);
227
226
  await this.auditTerminal(binding, context, "cancelled");
228
227
  }
229
-
230
228
  async openRunArtifact(runId: string, itemId: string, signal: AbortSignal) {
231
229
  const initial = await this.runs.find(runId);
232
230
  if (!initial) throw unavailableArtifact();
@@ -250,7 +248,6 @@ export class EmbeddingFacade {
250
248
  signal,
251
249
  );
252
250
  }
253
-
254
251
  private async prepare(
255
252
  operationKey: string,
256
253
  items: readonly EmbeddingFacadeItem[],
@@ -266,6 +263,10 @@ export class EmbeddingFacade {
266
263
  404,
267
264
  );
268
265
  await authorizeEmbeddingOperation(this.runtime, operation, context);
266
+ const readinessPin = await requireReadiness(
267
+ this.readiness,
268
+ operation.definition,
269
+ );
269
270
  const governed = governEmbeddingInput(items, options, operation.definition);
270
271
  const plan = await operation.registration.prepareEmbedding(governed, {
271
272
  actor: toModuleActor(context.actor),
@@ -316,6 +317,7 @@ export class EmbeddingFacade {
316
317
  },
317
318
  providerEgress: resolveProviderEgress(operation.definition),
318
319
  providerDataPolicy: operation.definition.providerDataPolicy,
320
+ readinessPin,
319
321
  },
320
322
  };
321
323
  }
@@ -0,0 +1,26 @@
1
+ import type { ModuleAiOperationDefinition } from "@southwind-ai/modules";
2
+ import type {
3
+ AiRuntimeDeploymentV1,
4
+ ModuleAiOperationReadinessPortV1,
5
+ } from "@southwind-ai/server-sdk";
6
+ import { EmbeddingFacadeError } from "./error.js";
7
+
8
+ export type ReadinessPort = ModuleAiOperationReadinessPortV1;
9
+
10
+ export async function requireReadiness(
11
+ readiness: ModuleAiOperationReadinessPortV1,
12
+ definition: ModuleAiOperationDefinition,
13
+ ): Promise<AiRuntimeDeploymentV1 | undefined> {
14
+ if (!definition.readiness) return undefined;
15
+ try {
16
+ const projected = await readiness.inspect({ operationKey: definition.key });
17
+ if (projected.status !== "ready") throw new Error("not ready");
18
+ return projected.deployment;
19
+ } catch {
20
+ throw new EmbeddingFacadeError(
21
+ "RUNTIME_UNAVAILABLE",
22
+ "Embedding Runtime 当前不可用",
23
+ 503,
24
+ );
25
+ }
26
+ }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { computeAiRuntimeOffersDigestV1 } from "@southwind-ai/server-sdk";
2
3
  import {
3
4
  RemoteEmbeddingRuntime,
4
5
  unavailableEmbeddingRuntime,
@@ -81,4 +82,88 @@ describe("RemoteEmbeddingRuntime", () => {
81
82
  runtime.start(operation, "run-key", "embedding_server_bound"),
82
83
  ).resolves.toEqual({ runId: "embedding_server_bound" });
83
84
  });
85
+
86
+ test("deployment pin 漂移时在创建远端 Run 前失败关闭", async () => {
87
+ const pin = embeddingPin();
88
+ let starts = 0;
89
+ const runtime = new RemoteEmbeddingRuntime({
90
+ baseUrl: "http://embedding.internal",
91
+ internalToken: "token",
92
+ fetch: async (input) => {
93
+ const url = String(input);
94
+ if (url.endsWith("/health/ready")) {
95
+ return Response.json(
96
+ readinessSnapshot(pin, `sha256:${"f".repeat(64)}`),
97
+ );
98
+ }
99
+ starts += 1;
100
+ return Response.json({ runId: "embedding_server_bound" });
101
+ },
102
+ });
103
+
104
+ await expect(
105
+ runtime.start(
106
+ { ...operation, readinessPin: pin },
107
+ "run-key",
108
+ "embedding_server_bound",
109
+ ),
110
+ ).rejects.toMatchObject({ code: "RUNTIME_UNAVAILABLE" });
111
+ expect(starts).toBe(0);
112
+ });
84
113
  });
114
+
115
+ function embeddingPin() {
116
+ const offersDigest = computeAiRuntimeOffersDigestV1([embeddingOffer()]);
117
+ return {
118
+ key: "embedding.primary",
119
+ identity: "windy-deployment-v1_0f26eb34-f384-4ec9-98f3-caf8d582291f",
120
+ releaseDigest: `sha256:${"a".repeat(64)}` as const,
121
+ offersDigest,
122
+ };
123
+ }
124
+
125
+ function readinessSnapshot(
126
+ pin: ReturnType<typeof embeddingPin>,
127
+ offersDigest = pin.offersDigest,
128
+ ) {
129
+ const now = new Date();
130
+ const checkedAt = now.toISOString();
131
+ const validUntil = new Date(now.getTime() + 60_000).toISOString();
132
+ const deployment = { ...pin, offersDigest };
133
+ return {
134
+ schemaVersion: 1,
135
+ status: "ready",
136
+ configured: true,
137
+ runtimeKind: "embedding",
138
+ observedAt: checkedAt,
139
+ downstreamProbe: {
140
+ status: "passed",
141
+ checkedAt,
142
+ validUntil,
143
+ latencyMs: 1,
144
+ deploymentIdentity: pin.identity,
145
+ releaseDigest: pin.releaseDigest,
146
+ offersDigest,
147
+ },
148
+ deployment,
149
+ offers: [embeddingOffer()],
150
+ unavailableReasons: [],
151
+ };
152
+ }
153
+
154
+ function embeddingOffer() {
155
+ return {
156
+ variant: "text",
157
+ capabilities: ["embedding.text"],
158
+ inputModalities: ["text"] as const,
159
+ outputModalities: ["embedding"] as const,
160
+ limits: {
161
+ maxDurationSeconds: 30,
162
+ maxInputBytes: 1024,
163
+ maxOutputBytes: 1024,
164
+ maxUnits: 10,
165
+ maxConcurrentRuns: 1,
166
+ maxQueueDepth: 0,
167
+ },
168
+ };
169
+ }
@@ -4,6 +4,7 @@ import {
4
4
  type EmbeddingRuntimeOperation,
5
5
  type EmbeddingRuntimePort,
6
6
  } from "./contracts.js";
7
+ import { assertAiRuntimeReadinessPin } from "../ai-runtime-readiness/deployment-pin.js";
7
8
 
8
9
  export class RemoteEmbeddingRuntime implements EmbeddingRuntimePort {
9
10
  readonly #baseUrl: string;
@@ -32,6 +33,7 @@ export class RemoteEmbeddingRuntime implements EmbeddingRuntimePort {
32
33
  idempotencyKey: string,
33
34
  signal?: AbortSignal,
34
35
  ): Promise<EmbeddingBatchResponse> {
36
+ await this.assertReadiness(request, signal);
35
37
  const response = await this.request("/api/embeddings", {
36
38
  method: "POST",
37
39
  headers: {
@@ -55,6 +57,7 @@ export class RemoteEmbeddingRuntime implements EmbeddingRuntimePort {
55
57
  runId: string,
56
58
  signal?: AbortSignal,
57
59
  ) {
60
+ await this.assertReadiness(request, signal);
58
61
  const response = await this.request("/api/embeddings/runs", {
59
62
  method: "POST",
60
63
  headers: {
@@ -125,6 +128,24 @@ export class RemoteEmbeddingRuntime implements EmbeddingRuntimePort {
125
128
  return response;
126
129
  });
127
130
  }
131
+
132
+ private async assertReadiness(
133
+ request: EmbeddingRuntimeOperation,
134
+ signal?: AbortSignal,
135
+ ) {
136
+ if (!request.readinessPin) return;
137
+ try {
138
+ await assertAiRuntimeReadinessPin(request.readinessPin, () =>
139
+ this.health(signal),
140
+ );
141
+ } catch {
142
+ throw new EmbeddingRuntimeError(
143
+ "RUNTIME_UNAVAILABLE",
144
+ "Embedding Runtime readiness 已漂移",
145
+ true,
146
+ );
147
+ }
148
+ }
128
149
  }
129
150
 
130
151
  export const unavailableEmbeddingRuntime: EmbeddingRuntimePort = {
@@ -2,6 +2,12 @@ import { expect, test } from "bun:test";
2
2
  import { embeddingRunFingerprint } from "./run-fingerprint.js";
3
3
 
4
4
  test("Run fingerprint 排除旋转后的 Artifact token,保留稳定输入与 options", async () => {
5
+ const pin = {
6
+ key: "embedding.primary",
7
+ identity: "windy-deployment-v1_0f26eb34-f384-4ec9-98f3-caf8d582291f",
8
+ releaseDigest: `sha256:${"a".repeat(64)}` as const,
9
+ offersDigest: `sha256:${"b".repeat(64)}` as const,
10
+ };
5
11
  const first = await embeddingRunFingerprint(
6
12
  "analysis.embedding.image",
7
13
  [
@@ -16,6 +22,7 @@ test("Run fingerprint 排除旋转后的 Artifact token,保留稳定输入与
16
22
  },
17
23
  ],
18
24
  { nested: { limit: 2 } },
25
+ pin,
19
26
  );
20
27
  const retry = await embeddingRunFingerprint(
21
28
  "analysis.embedding.image",
@@ -31,6 +38,7 @@ test("Run fingerprint 排除旋转后的 Artifact token,保留稳定输入与
31
38
  },
32
39
  ],
33
40
  { nested: { limit: 2 } },
41
+ pin,
34
42
  );
35
43
  const changed = await embeddingRunFingerprint(
36
44
  "analysis.embedding.image",
@@ -46,7 +54,25 @@ test("Run fingerprint 排除旋转后的 Artifact token,保留稳定输入与
46
54
  },
47
55
  ],
48
56
  { nested: { limit: 2 } },
57
+ { ...pin, offersDigest: `sha256:${"c".repeat(64)}` },
58
+ );
59
+ const deploymentChanged = await embeddingRunFingerprint(
60
+ "analysis.embedding.image",
61
+ [
62
+ {
63
+ id: "image-1",
64
+ source: {
65
+ value: "rotated-token-four",
66
+ mediaType: "image/png",
67
+ byteSize: 12,
68
+ sha256: "a".repeat(64),
69
+ },
70
+ },
71
+ ],
72
+ { nested: { limit: 2 } },
73
+ { ...pin, offersDigest: `sha256:${"c".repeat(64)}` },
49
74
  );
50
75
  expect(retry).toBe(first);
51
76
  expect(changed).not.toBe(first);
77
+ expect(deploymentChanged).not.toBe(first);
52
78
  });
@@ -1,15 +1,24 @@
1
- import type { ModuleAiJsonValue } from "@southwind-ai/server-sdk";
1
+ import type {
2
+ AiRuntimeDeploymentV1,
3
+ ModuleAiJsonValue,
4
+ } from "@southwind-ai/server-sdk";
2
5
  import type { EmbeddingRuntimeItem } from "./contracts.js";
3
6
 
4
7
  export async function embeddingRunFingerprint(
5
8
  operationKey: string,
6
9
  items: readonly EmbeddingRuntimeItem[],
7
10
  options: ModuleAiJsonValue,
11
+ readinessPin?: AiRuntimeDeploymentV1,
8
12
  ) {
9
13
  const digest = await crypto.subtle.digest(
10
14
  "SHA-256",
11
15
  new TextEncoder().encode(
12
- stableJson({ operationKey, items: items.map(stableItem), options }),
16
+ stableJson({
17
+ operationKey,
18
+ items: items.map(stableItem),
19
+ options,
20
+ ...(readinessPin ? { readinessPin } : {}),
21
+ }),
13
22
  ),
14
23
  );
15
24
  return [...new Uint8Array(digest)]