create-windy 0.6.0 → 0.6.2

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 (61) hide show
  1. package/dist/cli.js +142 -37
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/_bunfig.toml +5 -0
  5. package/template/apps/embedding-server/src/southwind_embedding_server/app.py +27 -20
  6. package/template/apps/embedding-server/src/southwind_embedding_server/config.py +111 -4
  7. package/template/apps/embedding-server/src/southwind_embedding_server/contracts.py +13 -0
  8. package/template/apps/embedding-server/src/southwind_embedding_server/pin_cli.py +62 -0
  9. package/template/apps/embedding-server/src/southwind_embedding_server/readiness.py +259 -0
  10. package/template/apps/embedding-server/src/southwind_embedding_server/readiness_contracts.py +221 -0
  11. package/template/apps/embedding-server/src/southwind_embedding_server/readiness_http.py +91 -0
  12. package/template/apps/embedding-server/src/southwind_embedding_server/run_service.py +23 -3
  13. package/template/apps/embedding-server/tests/test_config.py +13 -0
  14. package/template/apps/embedding-server/tests/test_readiness.py +202 -0
  15. package/template/apps/embedding-server/tests/test_run_http.py +76 -4
  16. package/template/apps/embedding-server/tests/test_run_service.py +3 -1
  17. package/template/apps/server/Dockerfile +10 -5
  18. package/template/apps/server/src/agent/drizzle-screenshot-intake-repository.integration.test.ts +3 -0
  19. package/template/apps/server/src/agent/run-facade.ts +2 -1
  20. package/template/apps/server/src/agent/run-route-screenshot-context.test.ts +13 -0
  21. package/template/apps/server/src/agent/runtime-bootstrap.ts +2 -0
  22. package/template/apps/server/src/agent/screenshot-context-admission.test.ts +249 -0
  23. package/template/apps/server/src/agent/screenshot-context-attestation.ts +60 -0
  24. package/template/apps/server/src/agent/screenshot-intake-bootstrap.ts +2 -4
  25. package/template/apps/server/src/agent/screenshot-intake-test-support.ts +4 -0
  26. package/template/apps/server/src/agent/screenshot-intake-v2-validation.ts +24 -0
  27. package/template/apps/server/src/agent/screenshot-intake-v2.test.ts +7 -0
  28. package/template/apps/server/src/agent/screenshot-intake-v2.ts +8 -10
  29. package/template/apps/server/src/agent/screenshot-run-binding.ts +17 -2
  30. package/template/apps/server/src/agent/vision-readiness-pin.ts +6 -48
  31. package/template/apps/server/src/ai-runtime-readiness/deployment-pin.ts +69 -0
  32. package/template/apps/server/src/embedding/contracts.ts +6 -1
  33. package/template/apps/server/src/embedding/facade-readiness.test.ts +307 -0
  34. package/template/apps/server/src/embedding/facade.ts +8 -6
  35. package/template/apps/server/src/embedding/readiness-admission.ts +26 -0
  36. package/template/apps/server/src/embedding/remote-runtime.test.ts +85 -0
  37. package/template/apps/server/src/embedding/remote-runtime.ts +21 -0
  38. package/template/apps/server/src/embedding/run-fingerprint.test.ts +26 -0
  39. package/template/apps/server/src/embedding/run-fingerprint.ts +11 -2
  40. package/template/apps/server/src/module-host/ai-operation-port.test.ts +72 -0
  41. package/template/apps/server/src/module-host/ai-operation-port.ts +43 -0
  42. package/template/apps/server/src/module-host/ai-operation-speech-normalize.test.ts +186 -0
  43. package/template/apps/web/Dockerfile +1 -1
  44. package/template/docker-compose.yml +11 -0
  45. package/template/docs/architecture/ai-runtime-readiness.md +6 -0
  46. package/template/docs/platform/vision-ai-runtime.md +22 -1
  47. package/template/package.json +2 -0
  48. package/template/packages/config/package.json +1 -1
  49. package/template/packages/database/package.json +1 -1
  50. package/template/packages/jobs/package.json +1 -1
  51. package/template/packages/modules/index.ts +1 -0
  52. package/template/packages/modules/package.json +1 -1
  53. package/template/packages/modules/src/ai-operation-composition.ts +2 -0
  54. package/template/packages/modules/src/ai-screenshot-context.test.ts +72 -0
  55. package/template/packages/modules/src/ai-screenshot-context.ts +50 -0
  56. package/template/packages/modules/src/manifest.ts +8 -0
  57. package/template/packages/server-sdk/package.json +1 -1
  58. package/template/packages/shared/index.ts +3 -0
  59. package/template/packages/shared/package.json +1 -1
  60. package/template/packages/shared/src/screenshot-context-digest.test.ts +46 -0
  61. package/template/packages/shared/src/screenshot-context-digest.ts +72 -0
@@ -31,9 +31,9 @@ import {
31
31
  bindingForScreenshot,
32
32
  assertScreenshotOperation,
33
33
  assertScreenshotProducerPlan,
34
+ canReserveScreenshot,
34
35
  sameScreenshotArtifact,
35
36
  sameScreenshotBinding,
36
- sameScreenshotDeployment,
37
37
  screenshotModuleName,
38
38
  screenshotPurpose,
39
39
  } from "./screenshot-intake-v2-validation.js";
@@ -42,8 +42,6 @@ const MAX_ENTRIES = 1_024;
42
42
  const MAX_TTL_MS = 60_000;
43
43
  const SWEEP_INTERVAL_MS = 10_000;
44
44
 
45
- export type { ProjectAiScreenshotIntakeFactoryV2 } from "./screenshot-intake-durable.js";
46
-
47
45
  export class ScreenshotIntakeRuntimeV2 {
48
46
  readonly #records = new Map<string, ScreenshotIntakeRecordV2>();
49
47
  readonly #sweeper: ReturnType<typeof setInterval>;
@@ -211,6 +209,7 @@ export class ScreenshotIntakeRuntimeV2 {
211
209
  token: string,
212
210
  operationKey: string,
213
211
  deployment: AiRuntimeDeploymentV1 | undefined,
212
+ expectedContextDigest: string | null | undefined,
214
213
  context: RequestGuardContext,
215
214
  signal: AbortSignal = AbortSignal.timeout(10_000),
216
215
  ): Promise<{
@@ -226,13 +225,12 @@ export class ScreenshotIntakeRuntimeV2 {
226
225
  context,
227
226
  );
228
227
  if (
229
- !record.artifact ||
230
- (!record.durable && record.state !== "uploaded") ||
231
- (record.durable &&
232
- !["uploaded", "reserving", "bound"].includes(record.state)) ||
233
- record.operationKey !== operationKey ||
234
- !deployment ||
235
- !sameScreenshotDeployment(record.deployment, deployment)
228
+ !canReserveScreenshot(
229
+ record,
230
+ operationKey,
231
+ deployment,
232
+ expectedContextDigest,
233
+ )
236
234
  ) {
237
235
  await this.cancel(record);
238
236
  throw unavailable();
@@ -1,9 +1,12 @@
1
1
  import type {
2
2
  AiRuntimeDeploymentV1,
3
3
  ModuleAiArtifactRef,
4
+ ModuleAiJsonValue,
4
5
  } from "@southwind-ai/server-sdk";
6
+ import type { ModuleAiOperationDefinition } from "@southwind-ai/modules";
5
7
  import type { RequestGuardContext } from "../guards.js";
6
8
  import { AgentRunFacadeError } from "./run-errors.js";
9
+ import { expectedScreenshotContextDigest } from "./screenshot-context-attestation.js";
7
10
  import type { ScreenshotIntakeRuntimeV2 } from "./screenshot-intake-v2.js";
8
11
 
9
12
  export interface ScreenshotRunBinding {
@@ -15,14 +18,26 @@ export interface ScreenshotRunBinding {
15
18
  export async function reserveScreenshotForRun(
16
19
  intake: ScreenshotIntakeRuntimeV2 | undefined,
17
20
  token: string | undefined,
18
- operationKey: string,
21
+ definition: ModuleAiOperationDefinition,
22
+ input: ModuleAiJsonValue,
19
23
  deployment: AiRuntimeDeploymentV1 | undefined,
20
24
  context: RequestGuardContext,
21
25
  signal: AbortSignal,
22
26
  ): Promise<ScreenshotRunBinding | undefined> {
23
27
  if (!token) return undefined;
24
28
  if (!intake) throw unavailable();
25
- return await intake.reserve(token, operationKey, deployment, context, signal);
29
+ const contextDigest = await expectedScreenshotContextDigest(
30
+ definition,
31
+ input,
32
+ );
33
+ return await intake.reserve(
34
+ token,
35
+ definition.key,
36
+ deployment,
37
+ contextDigest,
38
+ context,
39
+ signal,
40
+ );
26
41
  }
27
42
 
28
43
  function unavailable(): AgentRunFacadeError {
@@ -1,19 +1,15 @@
1
+ import type { AiRuntimeDeploymentV1 } from "@southwind-ai/server-sdk";
1
2
  import {
2
- parseAiRuntimeReadinessV1,
3
- type AiRuntimeDeploymentV1,
4
- } from "@southwind-ai/server-sdk";
3
+ assertAiRuntimeReadinessPin,
4
+ sameAiRuntimeDeployment,
5
+ } from "../ai-runtime-readiness/deployment-pin.js";
5
6
  import { AgentRuntimeError } from "./run-contracts.js";
6
7
 
7
8
  export function sameVisionReadinessDeployment(
8
9
  actual: AiRuntimeDeploymentV1,
9
10
  expected: AiRuntimeDeploymentV1,
10
11
  ): boolean {
11
- return (
12
- actual.key === expected.key &&
13
- actual.identity === expected.identity &&
14
- actual.releaseDigest === expected.releaseDigest &&
15
- actual.offersDigest === expected.offersDigest
16
- );
12
+ return sameAiRuntimeDeployment(actual, expected);
17
13
  }
18
14
 
19
15
  export function unavailableVisionReadiness(): AgentRuntimeError {
@@ -28,47 +24,9 @@ export async function assertVisionReadinessPin(
28
24
  expected: AiRuntimeDeploymentV1,
29
25
  health: () => Promise<Response>,
30
26
  ): Promise<void> {
31
- const response = await boundedHealth(health);
32
- if (response.status !== 200) throw unavailableVisionReadiness();
33
- if (!response.headers.get("content-type")?.includes("application/json")) {
34
- throw unavailableVisionReadiness();
35
- }
36
- const length = Number(response.headers.get("content-length") ?? "0");
37
- if (!Number.isSafeInteger(length) || length < 0 || length > 64 * 1_024) {
38
- throw unavailableVisionReadiness();
39
- }
40
- let snapshot: unknown;
41
27
  try {
42
- const text = await response.text();
43
- if (new TextEncoder().encode(text).byteLength > 64 * 1_024) {
44
- throw new Error("health body too large");
45
- }
46
- snapshot = JSON.parse(text);
28
+ await assertAiRuntimeReadinessPin(expected, health);
47
29
  } catch {
48
30
  throw unavailableVisionReadiness();
49
31
  }
50
- const parsed = parseAiRuntimeReadinessV1(snapshot, new Date().toISOString());
51
- if (
52
- !parsed.ok ||
53
- parsed.value.status !== "ready" ||
54
- !sameVisionReadinessDeployment(parsed.value.deployment, expected)
55
- ) {
56
- throw unavailableVisionReadiness();
57
- }
58
- }
59
-
60
- async function boundedHealth(
61
- health: () => Promise<Response>,
62
- ): Promise<Response> {
63
- let timeout: ReturnType<typeof setTimeout> | undefined;
64
- try {
65
- return await Promise.race([
66
- health(),
67
- new Promise<Response>((_, reject) => {
68
- timeout = setTimeout(() => reject(unavailableVisionReadiness()), 5_000);
69
- }),
70
- ]);
71
- } finally {
72
- if (timeout) clearTimeout(timeout);
73
- }
74
32
  }
@@ -0,0 +1,69 @@
1
+ import {
2
+ parseAiRuntimeReadinessV1,
3
+ type AiRuntimeDeploymentV1,
4
+ } from "@southwind-ai/server-sdk";
5
+
6
+ export function sameAiRuntimeDeployment(
7
+ actual: AiRuntimeDeploymentV1,
8
+ expected: AiRuntimeDeploymentV1,
9
+ ): boolean {
10
+ return (
11
+ actual.key === expected.key &&
12
+ actual.identity === expected.identity &&
13
+ actual.releaseDigest === expected.releaseDigest &&
14
+ actual.offersDigest === expected.offersDigest
15
+ );
16
+ }
17
+
18
+ export async function assertAiRuntimeReadinessPin(
19
+ expected: AiRuntimeDeploymentV1,
20
+ health: () => Promise<Response>,
21
+ ): Promise<void> {
22
+ const response = await boundedHealth(health);
23
+ if (response.status !== 200) throw unavailable();
24
+ if (!response.headers.get("content-type")?.includes("application/json")) {
25
+ throw unavailable();
26
+ }
27
+ const length = Number(response.headers.get("content-length") ?? "0");
28
+ if (!Number.isSafeInteger(length) || length < 0 || length > 64 * 1_024) {
29
+ throw unavailable();
30
+ }
31
+ let snapshot: unknown;
32
+ try {
33
+ const text = await response.text();
34
+ if (new TextEncoder().encode(text).byteLength > 64 * 1_024) {
35
+ throw new Error("health body too large");
36
+ }
37
+ snapshot = JSON.parse(text);
38
+ } catch {
39
+ throw unavailable();
40
+ }
41
+ const parsed = parseAiRuntimeReadinessV1(snapshot, new Date().toISOString());
42
+ if (
43
+ !parsed.ok ||
44
+ parsed.value.status !== "ready" ||
45
+ !sameAiRuntimeDeployment(parsed.value.deployment, expected)
46
+ ) {
47
+ throw unavailable();
48
+ }
49
+ }
50
+
51
+ async function boundedHealth(
52
+ health: () => Promise<Response>,
53
+ ): Promise<Response> {
54
+ let timeout: ReturnType<typeof setTimeout> | undefined;
55
+ try {
56
+ return await Promise.race([
57
+ health(),
58
+ new Promise<Response>((_, reject) => {
59
+ timeout = setTimeout(() => reject(unavailable()), 5_000);
60
+ }),
61
+ ]);
62
+ } finally {
63
+ if (timeout) clearTimeout(timeout);
64
+ }
65
+ }
66
+
67
+ function unavailable(): Error {
68
+ return new Error("AI Runtime readiness pin mismatch");
69
+ }
@@ -4,7 +4,10 @@ import type {
4
4
  AiProviderDataPolicy,
5
5
  AiProviderEgressPolicy,
6
6
  } from "@southwind-ai/modules";
7
- import type { ModuleAiJsonValue } from "@southwind-ai/server-sdk";
7
+ import type {
8
+ AiRuntimeDeploymentV1,
9
+ ModuleAiJsonValue,
10
+ } from "@southwind-ai/server-sdk";
8
11
 
9
12
  export type EmbeddingRuntimeItem =
10
13
  | { readonly id: string; readonly text: string }
@@ -36,6 +39,8 @@ export interface EmbeddingRuntimeOperation {
36
39
  };
37
40
  readonly providerEgress: AiProviderEgressPolicy;
38
41
  readonly providerDataPolicy?: AiProviderDataPolicy;
42
+ /** Host-projected immutable deployment facts; never supplied by a Module. */
43
+ readonly readinessPin?: AiRuntimeDeploymentV1;
39
44
  }
40
45
 
41
46
  export interface EmbeddingBatchResponse {
@@ -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
+ }