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
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
 
7
7
  import httpx
8
8
  import pytest
9
+ from fastapi import FastAPI
9
10
 
10
11
  os.environ.setdefault("EMBEDDING_SERVER_INTERNAL_TOKEN", "test-internal")
11
12
  os.environ.setdefault("EMBEDDING_RUN_ENCRYPTION_SECRET", "x" * 32)
@@ -19,6 +20,11 @@ from southwind_embedding_server.app import create_app
19
20
  from southwind_embedding_server.config import EmbeddingConfig
20
21
  from southwind_embedding_server.contracts import EmbeddingRequest, EmbeddingResponse
21
22
  from southwind_embedding_server.error import SafeEmbeddingError
23
+ from southwind_embedding_server.readiness import EmbeddingReadinessInspection
24
+
25
+ IDENTITY = "windy-deployment-v1_0f26eb34-f384-4ec9-98f3-caf8d582291f"
26
+ DIGEST_A = f"sha256:{'a' * 64}"
27
+ DIGEST_B = f"sha256:{'b' * 64}"
22
28
 
23
29
 
24
30
  def config(path: str) -> EmbeddingConfig:
@@ -28,7 +34,10 @@ def config(path: str) -> EmbeddingConfig:
28
34
  run_artifact_url="http://server:9747/api/internal/embeddings/runs",
29
35
  adapter_url="http://adapter:9000",
30
36
  adapter_token=None,
31
- deployment_key=None,
37
+ deployment_key="embedding.primary",
38
+ deployment_identity=IDENTITY,
39
+ release_digest=DIGEST_A,
40
+ offers_digest=DIGEST_B,
32
41
  deployment_alias="intranet-v1",
33
42
  model="embedding-model",
34
43
  version="2026-08",
@@ -56,6 +65,12 @@ def request() -> dict[str, object]:
56
65
  "maxUnits": 10,
57
66
  },
58
67
  "providerEgress": {"mode": "forbidden"},
68
+ "readinessPin": {
69
+ "key": "embedding.primary",
70
+ "identity": IDENTITY,
71
+ "releaseDigest": DIGEST_A,
72
+ "offersDigest": DIGEST_B,
73
+ },
59
74
  }
60
75
 
61
76
 
@@ -78,7 +93,7 @@ def response() -> EmbeddingResponse:
78
93
  @pytest.mark.asyncio
79
94
  async def test_run_http_success_events_result_and_cancel(tmp_path: Path) -> None:
80
95
  adapter = Adapter(response())
81
- app = create_app(config(str(tmp_path / "runs.sqlite3")), adapter)
96
+ app = create_app(config(str(tmp_path / "runs.sqlite3")), adapter, ReadyReadiness())
82
97
  async with client(app) as remote:
83
98
  denied = await remote.post("/api/embeddings/runs", json=request())
84
99
  assert denied.status_code == 403
@@ -103,7 +118,9 @@ async def test_run_http_success_events_result_and_cancel(tmp_path: Path) -> None
103
118
  assert result.json()["output"]["batchId"] == "batch-1"
104
119
 
105
120
  waiting = Adapter(response(), block=True)
106
- cancelled_app = create_app(config(str(tmp_path / "cancel.sqlite3")), waiting)
121
+ cancelled_app = create_app(
122
+ config(str(tmp_path / "cancel.sqlite3")), waiting, ReadyReadiness()
123
+ )
107
124
  async with client(cancelled_app) as cancel_remote:
108
125
  accepted = await cancel_remote.post(
109
126
  "/api/embeddings/runs", headers=headers("cancel-1"), json=request()
@@ -122,6 +139,7 @@ async def test_run_http_exposes_safe_remote_failure_only(tmp_path: Path) -> None
122
139
  app = create_app(
123
140
  config(str(tmp_path / "failed.sqlite3")),
124
141
  Adapter(None, failure=True),
142
+ ReadyReadiness(),
125
143
  )
126
144
  async with client(app) as remote:
127
145
  started = await remote.post(
@@ -143,6 +161,24 @@ async def test_run_http_exposes_safe_remote_failure_only(tmp_path: Path) -> None
143
161
  assert result.json()["code"] == "RUN_NOT_READY"
144
162
 
145
163
 
164
+ @pytest.mark.asyncio
165
+ async def test_unready_request_does_not_create_run(tmp_path: Path) -> None:
166
+ app = create_app(
167
+ config(str(tmp_path / "unready.sqlite3")),
168
+ Adapter(response()),
169
+ UnreadyReadiness(),
170
+ )
171
+ async with client(app) as remote:
172
+ rejected = await remote.post(
173
+ "/api/embeddings/runs", headers=headers("unready"), json=request()
174
+ )
175
+ assert rejected.status_code == 503
176
+ missing = await remote.get(
177
+ "/api/embeddings/runs/embedding_unready/result", headers=headers()
178
+ )
179
+ assert missing.status_code == 404
180
+
181
+
146
182
  class Adapter:
147
183
  def __init__(
148
184
  self,
@@ -171,6 +207,42 @@ class Adapter:
171
207
  return self.value
172
208
 
173
209
 
210
+ class ReadyReadiness:
211
+ async def inspect(self) -> EmbeddingReadinessInspection:
212
+ return EmbeddingReadinessInspection(
213
+ {
214
+ "status": "ready",
215
+ "deployment": {
216
+ "key": "embedding.primary",
217
+ "identity": IDENTITY,
218
+ "releaseDigest": DIGEST_A,
219
+ "offersDigest": DIGEST_B,
220
+ },
221
+ },
222
+ ({"variant": "text"},),
223
+ )
224
+
225
+ def supports(
226
+ self, _inspection: EmbeddingReadinessInspection, request: EmbeddingRequest
227
+ ) -> bool:
228
+ return bool(
229
+ request.readiness_pin
230
+ and request.readiness_pin.identity == IDENTITY
231
+ and request.readiness_pin.release_digest == DIGEST_A
232
+ and request.readiness_pin.offers_digest == DIGEST_B
233
+ )
234
+
235
+
236
+ class UnreadyReadiness(ReadyReadiness):
237
+ async def inspect(self) -> EmbeddingReadinessInspection:
238
+ return EmbeddingReadinessInspection({"status": "unavailable"})
239
+
240
+ def supports(
241
+ self, _inspection: EmbeddingReadinessInspection, _request: EmbeddingRequest
242
+ ) -> bool:
243
+ return False
244
+
245
+
174
246
  def headers(key: str = "read") -> dict[str, str]:
175
247
  return {
176
248
  "authorization": "Bearer internal",
@@ -179,7 +251,7 @@ def headers(key: str = "read") -> dict[str, str]:
179
251
  }
180
252
 
181
253
 
182
- def client(app: object) -> httpx.AsyncClient:
254
+ def client(app: FastAPI) -> httpx.AsyncClient:
183
255
  return httpx.AsyncClient(
184
256
  transport=httpx.ASGITransport(app=app), base_url="http://embedding"
185
257
  )
@@ -181,7 +181,9 @@ async def test_recovery_fails_closed_when_deployment_pin_drifts(tmp_path: Path)
181
181
  await asyncio.sleep(0)
182
182
  events = await restarted.store.events_after(pending.run_id, 0)
183
183
  assert events[-1]["type"] == "run.failed"
184
- assert events[-1]["error"]["code"] == "CAPABILITY_UNAVAILABLE"
184
+ error = events[-1]["error"]
185
+ assert isinstance(error, dict)
186
+ assert error["code"] == "CAPABILITY_UNAVAILABLE"
185
187
 
186
188
 
187
189
  def response() -> EmbeddingResponse:
@@ -1,6 +1,6 @@
1
1
  # syntax=docker/dockerfile:1
2
2
 
3
- FROM oven/bun:1.3.14-alpine AS deps
3
+ FROM oven/bun:1.3.14 AS deps
4
4
  WORKDIR /app
5
5
 
6
6
  COPY package.json bun.lock ./
@@ -23,12 +23,16 @@ COPY packages/server-sdk/package.json packages/server-sdk/package.json
23
23
  COPY packages/shared/package.json packages/shared/package.json
24
24
  COPY packages/storage/package.json packages/storage/package.json
25
25
  # windy-copy:deps end
26
- RUN --mount=type=cache,id=windy-bun-1.3.14,target=/root/.bun/install/cache,sharing=shared bun -e 'const child=Bun.spawn(["bun","install","--frozen-lockfile","--no-progress"],{stdin:"inherit",stdout:"inherit",stderr:"inherit"});const heartbeat=setInterval(()=>console.log("[windy] Bun 依赖安装仍在进行"),15000);const exitCode=await child.exited;clearInterval(heartbeat);process.exit(exitCode)'
26
+ RUN --mount=type=cache,id=windy-bun-1.3.14,target=/root/.bun/install/cache,sharing=shared bun -e 'const child=Bun.spawn(["bun","install","--frozen-lockfile","--no-progress","--backend=copyfile","--linker=hoisted"],{stdin:"inherit",stdout:"inherit",stderr:"inherit"});const heartbeat=setInterval(()=>console.log("[windy] Bun 依赖安装仍在进行"),15000);const exitCode=await child.exited;clearInterval(heartbeat);process.exit(exitCode)'
27
27
 
28
28
  FROM deps AS runner
29
29
  # @windy-module system.storage begin
30
- RUN apk add --no-cache libarchive=3.8.3-r0 libarchive-tools=3.8.3-r0 \
31
- && apk add --no-cache --virtual .windy-report-staging-build build-base=0.5-r3 libarchive-dev=3.8.3-r0
30
+ # The native archive helper needs a compiler. Avoid Alpine apk here: its source
31
+ # transfer can stall indefinitely under cross-architecture BuildKit emulation.
32
+ RUN set -eu; \
33
+ if ! timeout --kill-after=5s 90s sh -c 'apt-get update -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 -o Acquire::Retries=0 && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends --yes build-essential libarchive-dev libarchive-tools'; then \
34
+ echo "APT 包源不可用或 90 秒总时限已到:无法安装 report-staging 依赖" >&2; exit 70; \
35
+ fi
32
36
  # @windy-module system.storage end
33
37
  COPY drizzle.config.ts drizzle.config.ts
34
38
  COPY apps/server apps/server
@@ -40,7 +44,8 @@ RUN cc -std=c11 -O2 -Wall -Wextra -Werror \
40
44
  && strip /usr/local/bin/windy-report-archive-stream \
41
45
  && chown root:root /usr/local/bin/windy-report-archive-stream \
42
46
  && chmod 0555 /usr/local/bin/windy-report-archive-stream \
43
- && apk del .windy-report-staging-build
47
+ && apt-get purge --auto-remove --yes build-essential libarchive-dev \
48
+ && rm -rf /var/lib/apt/lists/*
44
49
  # @windy-module system.storage end
45
50
  COPY --chmod=0555 deploy/production/server-entrypoint.sh /usr/local/bin/windy-server-entrypoint
46
51
  RUN mkdir -p /app/data/audit /app/data/license && chown -R bun:bun /app/data
@@ -154,6 +154,7 @@ describePostgres("Screenshot Intake V2 真实 PostgreSQL 重启与 TTL", () => {
154
154
  plan.intakeToken,
155
155
  "evidence.image.inspect",
156
156
  screenshotDeployment,
157
+ declaration(bytes).contextDigest,
157
158
  context,
158
159
  );
159
160
  expect(first.artifact.value).toStartWith("v1.");
@@ -167,6 +168,7 @@ describePostgres("Screenshot Intake V2 真实 PostgreSQL 重启与 TTL", () => {
167
168
  plan.intakeToken,
168
169
  "evidence.image.inspect",
169
170
  screenshotDeployment,
171
+ declaration(bytes).contextDigest,
170
172
  context,
171
173
  );
172
174
  await recovered.bind("run-canonical");
@@ -177,6 +179,7 @@ describePostgres("Screenshot Intake V2 真实 PostgreSQL 重启与 TTL", () => {
177
179
  plan.intakeToken,
178
180
  "evidence.image.inspect",
179
181
  screenshotDeployment,
182
+ declaration(bytes).contextDigest,
180
183
  context,
181
184
  );
182
185
  await idempotent.bind("run-canonical");
@@ -200,7 +200,8 @@ export class AgentRunFacade {
200
200
  screenshot = await reserveScreenshotForRun(
201
201
  this.screenshots,
202
202
  screenshotIntakeToken,
203
- operationKey,
203
+ operation.definition,
204
+ input,
204
205
  readinessPin,
205
206
  context,
206
207
  signal,
@@ -0,0 +1,13 @@
1
+ import { expect, test } from "bun:test";
2
+ import { parseStartRequest } from "./run-route-input.js";
3
+
4
+ test("Run route 不接受客户端自报 Screenshot context 已验证", () => {
5
+ expect(() =>
6
+ parseStartRequest({
7
+ operationKey: "evidence.image.inspect",
8
+ input: { uiContext: { version: 1 } },
9
+ screenshotIntakeToken: "0198c47d-7d51-7000-8000-000000000001",
10
+ screenshotContextDigest: `sha256:${"a".repeat(64)}`,
11
+ }),
12
+ ).toThrow("请求格式无效");
13
+ });
@@ -263,6 +263,7 @@ export function registerAgentPlatformRuntime(input: {
263
263
  : new InMemoryEmbeddingRunBindingRepository(),
264
264
  internalToken,
265
265
  ),
266
+ aiReadiness,
266
267
  );
267
268
  registerEmbeddingRoutes(aiRouteApp, embeddingFacade);
268
269
  registerEmbeddingRunArtifactRoutes(
@@ -316,6 +317,7 @@ export function registerAgentPlatformRuntime(input: {
316
317
  speechFacade,
317
318
  speechRemote,
318
319
  embeddingFacade,
320
+ modules: input.modules,
319
321
  });
320
322
  const aiReadinessForModule = (
321
323
  moduleName: string,
@@ -0,0 +1,249 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ModuleManifest } from "@southwind-ai/modules";
3
+ import { computeScreenshotContextDigestV1 } from "@southwind-ai/shared";
4
+ import type { ModuleAiScreenshotIntakeProducerV2 } from "@southwind-ai/server-sdk";
5
+ import { buildScopeContext } from "../data-access/context.js";
6
+ import { createRequestGuardContext } from "../guards.js";
7
+ import { createServerRuntime } from "../runtime.js";
8
+ import { ScreenshotIntakeRuntimeV2 } from "./screenshot-intake-v2.js";
9
+ import { reserveScreenshotForRun } from "./screenshot-run-binding.js";
10
+ import {
11
+ screenshotDeployment,
12
+ screenshotVisionManifest,
13
+ } from "./screenshot-intake-test-support.js";
14
+
15
+ describe("Screenshot context Run admission", () => {
16
+ test("Host 重算 Manifest 指向的结构化上下文后才允许绑定", async () => {
17
+ const producer = fakeProducer();
18
+ const intake = runtime(producer);
19
+ const context = guard();
20
+ const uiContext = {
21
+ version: 1,
22
+ pageKey: "case.detail",
23
+ overlayStack: [{ title: "导入物证", kind: "dialog" }],
24
+ };
25
+ const plan = await intake.create(
26
+ declaration(await computeScreenshotContextDigestV1(uiContext)),
27
+ context,
28
+ signal(),
29
+ );
30
+ await intake.upload(plan.intakeToken, "image/png", png(), context);
31
+
32
+ const binding = await reserveScreenshotForRun(
33
+ intake,
34
+ plan.intakeToken,
35
+ operation(),
36
+ {
37
+ uiContext: {
38
+ overlayStack: [{ kind: "dialog", title: "导入物证" }],
39
+ pageKey: "case.detail",
40
+ version: 1,
41
+ },
42
+ uiContextDigest: `sha256:${"0".repeat(64)}`,
43
+ },
44
+ screenshotDeployment,
45
+ context,
46
+ signal(),
47
+ );
48
+ expect(binding?.artifact.value).toBe("opaque-screenshot-artifact");
49
+ await binding?.bind("run-canonical");
50
+ expect(producer.bound).toEqual(["run-canonical"]);
51
+ await intake.dispose();
52
+ });
53
+
54
+ test("上下文漂移或只提交自报 digest 会消费 token 并拒绝重放", async () => {
55
+ const producer = fakeProducer();
56
+ const intake = runtime(producer);
57
+ const context = guard();
58
+ const uiContext = { version: 1, pageKey: "case.detail" };
59
+ const first = await intake.create(
60
+ declaration(await computeScreenshotContextDigestV1(uiContext)),
61
+ context,
62
+ signal(),
63
+ );
64
+ await intake.upload(first.intakeToken, "image/png", png(), context);
65
+
66
+ await expect(
67
+ reserveScreenshotForRun(
68
+ intake,
69
+ first.intakeToken,
70
+ operation(),
71
+ { uiContext: { version: 1, pageKey: "case.list" } },
72
+ screenshotDeployment,
73
+ context,
74
+ signal(),
75
+ ),
76
+ ).rejects.toMatchObject({ code: "RUNTIME_UNAVAILABLE", status: 503 });
77
+ await expect(
78
+ reserveScreenshotForRun(
79
+ intake,
80
+ first.intakeToken,
81
+ operation(),
82
+ { uiContext },
83
+ screenshotDeployment,
84
+ context,
85
+ signal(),
86
+ ),
87
+ ).rejects.toMatchObject({ code: "RUNTIME_UNAVAILABLE", status: 503 });
88
+
89
+ const second = await intake.create(
90
+ declaration(await computeScreenshotContextDigestV1(uiContext)),
91
+ context,
92
+ signal(),
93
+ );
94
+ await intake.upload(second.intakeToken, "image/png", png(), context);
95
+ await expect(
96
+ reserveScreenshotForRun(
97
+ intake,
98
+ second.intakeToken,
99
+ operation(),
100
+ { uiContextDigest: await computeScreenshotContextDigestV1(uiContext) },
101
+ screenshotDeployment,
102
+ context,
103
+ signal(),
104
+ ),
105
+ ).rejects.toMatchObject({ code: "RUNTIME_UNAVAILABLE", status: 503 });
106
+ expect(producer.cancelled).toEqual(["producer-plan", "producer-plan"]);
107
+ await intake.dispose();
108
+ });
109
+
110
+ test("未 opt-in 的 0.6.0 Screenshot Operation 保留既有兼容边界", async () => {
111
+ const producer = fakeProducer();
112
+ const manifest = legacyManifest();
113
+ const intake = runtime(producer, manifest);
114
+ const context = guard();
115
+ const plan = await intake.create(
116
+ declaration(`sha256:${"f".repeat(64)}`),
117
+ context,
118
+ signal(),
119
+ );
120
+ await intake.upload(plan.intakeToken, "image/png", png(), context);
121
+ const binding = await reserveScreenshotForRun(
122
+ intake,
123
+ plan.intakeToken,
124
+ manifest.aiOperations![0]!,
125
+ { uiContext: { version: 1, pageKey: "legacy" } },
126
+ screenshotDeployment,
127
+ context,
128
+ signal(),
129
+ );
130
+ expect(binding?.artifact.value).toBe("opaque-screenshot-artifact");
131
+ await binding?.bind("run-legacy");
132
+ await intake.dispose();
133
+ });
134
+ });
135
+
136
+ function operation() {
137
+ const value = screenshotVisionManifest().aiOperations?.[0];
138
+ if (!value) throw new Error("测试 Manifest 缺少 Vision Operation");
139
+ return value;
140
+ }
141
+
142
+ function runtime(
143
+ producer: ReturnType<typeof fakeProducer>,
144
+ manifest = screenshotVisionManifest(),
145
+ ) {
146
+ return new ScreenshotIntakeRuntimeV2(
147
+ [manifest],
148
+ createServerRuntime(
149
+ {},
150
+ {
151
+ featureCatalog: manifest.features,
152
+ licenseResolver: { resolve: () => ({ status: "valid" }) },
153
+ },
154
+ ),
155
+ () => producer,
156
+ (reference) => reference,
157
+ );
158
+ }
159
+
160
+ function legacyManifest(): ModuleManifest {
161
+ const manifest = screenshotVisionManifest();
162
+ const definition = manifest.aiOperations?.[0];
163
+ if (!definition) throw new Error("测试 Manifest 缺少 Vision Operation");
164
+ return {
165
+ ...manifest,
166
+ aiOperations: [{ ...definition, screenshotContext: undefined }],
167
+ };
168
+ }
169
+
170
+ function fakeProducer(): ModuleAiScreenshotIntakeProducerV2 & {
171
+ bound: string[];
172
+ cancelled: string[];
173
+ } {
174
+ return {
175
+ bound: [],
176
+ cancelled: [],
177
+ async create() {
178
+ return {
179
+ contractVersion: 2,
180
+ intakeToken: "producer-plan",
181
+ operationKey: "evidence.image.inspect",
182
+ expiresAt: new Date(Date.now() + 30_000).toISOString(),
183
+ };
184
+ },
185
+ async upload() {
186
+ return {
187
+ value: "opaque-screenshot-artifact",
188
+ mediaType: "image/png",
189
+ byteSize: png().byteLength,
190
+ sha256:
191
+ "b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640",
192
+ expiresAt: new Date(Date.now() + 20_000).toISOString(),
193
+ };
194
+ },
195
+ async bind({ runId }) {
196
+ this.bound.push(runId);
197
+ },
198
+ async cancel({ intakeToken }) {
199
+ this.cancelled.push(intakeToken);
200
+ },
201
+ };
202
+ }
203
+
204
+ function declaration(contextDigest: `sha256:${string}`) {
205
+ return {
206
+ pageId: "case-detail",
207
+ contextDigest,
208
+ mediaType: "image/png" as const,
209
+ byteSize: png().byteLength,
210
+ pixels: 1,
211
+ };
212
+ }
213
+
214
+ function guard() {
215
+ return createRequestGuardContext(
216
+ new Request("http://windy.test/api/agent/screenshots"),
217
+ {
218
+ resolveActor: () => ({
219
+ id: "actor-1",
220
+ type: "user",
221
+ name: "测试用户",
222
+ roleCodes: ["user"],
223
+ permissionKeys: ["evidence.ai.execute"],
224
+ scopeContext: buildScopeContext("actor-1", undefined, [
225
+ {
226
+ id: "role-user",
227
+ code: "user",
228
+ dataScope: "self",
229
+ departmentIds: [],
230
+ },
231
+ ]),
232
+ }),
233
+ resolveLicense: () => ({ status: "valid" }),
234
+ },
235
+ );
236
+ }
237
+
238
+ function signal() {
239
+ return AbortSignal.timeout(1_000);
240
+ }
241
+
242
+ function png(): Uint8Array {
243
+ return Uint8Array.from(
244
+ Buffer.from(
245
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC",
246
+ "base64",
247
+ ),
248
+ );
249
+ }
@@ -0,0 +1,60 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import {
3
+ parseAiScreenshotContextInputPointer,
4
+ type ModuleAiOperationDefinition,
5
+ } from "@southwind-ai/modules";
6
+ import {
7
+ computeScreenshotContextDigestV1,
8
+ isScreenshotContextDigestV1,
9
+ type JsonValue,
10
+ type ScreenshotContextDigestV1,
11
+ } from "@southwind-ai/shared";
12
+
13
+ /** null 表示 legacy 未 opt-in;undefined 表示已 opt-in 但上下文不能形成证明。 */
14
+ export async function expectedScreenshotContextDigest(
15
+ definition: ModuleAiOperationDefinition,
16
+ input: JsonValue,
17
+ ): Promise<ScreenshotContextDigestV1 | null | undefined> {
18
+ const binding = definition.screenshotContext;
19
+ if (!binding) return null;
20
+ try {
21
+ const context = readPointer(
22
+ input,
23
+ parseAiScreenshotContextInputPointer(binding.contextInputPointer),
24
+ );
25
+ if (!isRecord(context)) return undefined;
26
+ return await computeScreenshotContextDigestV1(context);
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ export function sameScreenshotContextDigest(
33
+ actual: string,
34
+ expected: string | undefined,
35
+ ): boolean {
36
+ if (
37
+ !isScreenshotContextDigestV1(actual) ||
38
+ !isScreenshotContextDigestV1(expected)
39
+ ) {
40
+ return false;
41
+ }
42
+ const actualBytes = Buffer.from(actual.slice("sha256:".length), "hex");
43
+ const expectedBytes = Buffer.from(expected.slice("sha256:".length), "hex");
44
+ return timingSafeEqual(actualBytes, expectedBytes);
45
+ }
46
+
47
+ function readPointer(value: JsonValue, segments: readonly string[]): JsonValue {
48
+ let current = value;
49
+ for (const segment of segments) {
50
+ if (!isRecord(current) || !Object.hasOwn(current, segment)) {
51
+ throw new Error("Screenshot context input 缺失");
52
+ }
53
+ current = current[segment] as JsonValue;
54
+ }
55
+ return current;
56
+ }
57
+
58
+ function isRecord(value: JsonValue): value is Record<string, JsonValue> {
59
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
60
+ }
@@ -9,10 +9,8 @@ import {
9
9
  PersistentScreenshotIntakeProducer,
10
10
  type ScreenshotPageRegistryV2,
11
11
  } from "./persistent-screenshot-intake-producer.js";
12
- import {
13
- ScreenshotIntakeRuntimeV2,
14
- type ProjectAiScreenshotIntakeFactoryV2,
15
- } from "./screenshot-intake-v2.js";
12
+ import { type ProjectAiScreenshotIntakeFactoryV2 } from "./screenshot-intake-durable.js";
13
+ import { ScreenshotIntakeRuntimeV2 } from "./screenshot-intake-v2.js";
16
14
 
17
15
  export function createScreenshotIntakeRuntimeV2(input: {
18
16
  readonly modules: readonly ModuleManifest[];
@@ -81,6 +81,10 @@ export function screenshotVisionManifest(): ModuleManifest {
81
81
  minConcurrentRuns: 1,
82
82
  minQueueDepth: 0,
83
83
  },
84
+ screenshotContext: {
85
+ contractVersion: 1,
86
+ contextInputPointer: "/uiContext",
87
+ },
84
88
  },
85
89
  ],
86
90
  };
@@ -8,6 +8,8 @@ import type {
8
8
  ModuleAiScreenshotPlanV2,
9
9
  } from "@southwind-ai/server-sdk";
10
10
  import type { RequestGuardContext } from "../guards.js";
11
+ import { sameScreenshotContextDigest } from "./screenshot-context-attestation.js";
12
+ import type { ScreenshotIntakeRecordV2 } from "./screenshot-intake-record.js";
11
13
 
12
14
  const MAX_BYTES = 8 * 1_024 * 1_024;
13
15
  const MAX_PIXELS = 20_000_000;
@@ -114,6 +116,28 @@ export function sameScreenshotDeployment(
114
116
  );
115
117
  }
116
118
 
119
+ export function canReserveScreenshot(
120
+ record: ScreenshotIntakeRecordV2,
121
+ operationKey: string,
122
+ deployment: AiRuntimeDeploymentV1 | undefined,
123
+ expectedContextDigest: string | null | undefined,
124
+ ): record is ScreenshotIntakeRecordV2 & { artifact: ModuleAiArtifactRef } {
125
+ return Boolean(
126
+ record.artifact &&
127
+ (record.durable
128
+ ? ["uploaded", "reserving", "bound"].includes(record.state)
129
+ : record.state === "uploaded") &&
130
+ record.operationKey === operationKey &&
131
+ (expectedContextDigest === null ||
132
+ sameScreenshotContextDigest(
133
+ record.contextDigest,
134
+ expectedContextDigest,
135
+ )) &&
136
+ deployment &&
137
+ sameScreenshotDeployment(record.deployment, deployment),
138
+ );
139
+ }
140
+
117
141
  export function sameScreenshotArtifact(
118
142
  value: ModuleAiArtifactRef,
119
143
  record: {
@@ -35,6 +35,7 @@ describe("Screenshot Intake V2", () => {
35
35
  ...deployment,
36
36
  offersDigest: `sha256:${"b".repeat(64)}` as `sha256:${string}`,
37
37
  },
38
+ declaration().contextDigest,
38
39
  context,
39
40
  ),
40
41
  ).rejects.toThrow("Screenshot Intake");
@@ -50,6 +51,7 @@ describe("Screenshot Intake V2", () => {
50
51
  second.intakeToken,
51
52
  "evidence.image.inspect",
52
53
  deployment,
54
+ declaration().contextDigest,
53
55
  context,
54
56
  );
55
57
  expect(reserved.artifact.value).toBe("opaque-screenshot-artifact");
@@ -59,6 +61,7 @@ describe("Screenshot Intake V2", () => {
59
61
  second.intakeToken,
60
62
  "evidence.image.inspect",
61
63
  deployment,
64
+ declaration().contextDigest,
62
65
  context,
63
66
  ),
64
67
  ).rejects.toThrow("Screenshot Intake");
@@ -332,6 +335,10 @@ function manifest(): ModuleManifest {
332
335
  minConcurrentRuns: 1,
333
336
  minQueueDepth: 0,
334
337
  },
338
+ screenshotContext: {
339
+ contractVersion: 1,
340
+ contextInputPointer: "/uiContext",
341
+ },
335
342
  },
336
343
  ],
337
344
  };