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.
- package/dist/cli.js +82 -2
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/embedding-server/src/southwind_embedding_server/app.py +27 -20
- package/template/apps/embedding-server/src/southwind_embedding_server/config.py +111 -4
- package/template/apps/embedding-server/src/southwind_embedding_server/contracts.py +13 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/pin_cli.py +62 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/readiness.py +259 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/readiness_contracts.py +221 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/readiness_http.py +91 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/run_service.py +23 -3
- package/template/apps/embedding-server/tests/test_config.py +13 -0
- package/template/apps/embedding-server/tests/test_readiness.py +202 -0
- package/template/apps/embedding-server/tests/test_run_http.py +76 -4
- package/template/apps/embedding-server/tests/test_run_service.py +3 -1
- package/template/apps/server/src/agent/drizzle-screenshot-intake-repository.integration.test.ts +3 -0
- package/template/apps/server/src/agent/run-facade.ts +2 -1
- package/template/apps/server/src/agent/run-route-screenshot-context.test.ts +13 -0
- package/template/apps/server/src/agent/runtime-bootstrap.ts +2 -0
- package/template/apps/server/src/agent/screenshot-context-admission.test.ts +249 -0
- package/template/apps/server/src/agent/screenshot-context-attestation.ts +60 -0
- package/template/apps/server/src/agent/screenshot-intake-bootstrap.ts +2 -4
- package/template/apps/server/src/agent/screenshot-intake-test-support.ts +4 -0
- package/template/apps/server/src/agent/screenshot-intake-v2-validation.ts +24 -0
- package/template/apps/server/src/agent/screenshot-intake-v2.test.ts +7 -0
- package/template/apps/server/src/agent/screenshot-intake-v2.ts +8 -10
- package/template/apps/server/src/agent/screenshot-run-binding.ts +17 -2
- package/template/apps/server/src/agent/vision-readiness-pin.ts +6 -48
- package/template/apps/server/src/ai-runtime-readiness/deployment-pin.ts +69 -0
- package/template/apps/server/src/embedding/contracts.ts +6 -1
- package/template/apps/server/src/embedding/facade-readiness.test.ts +307 -0
- package/template/apps/server/src/embedding/facade.ts +8 -6
- package/template/apps/server/src/embedding/readiness-admission.ts +26 -0
- package/template/apps/server/src/embedding/remote-runtime.test.ts +85 -0
- package/template/apps/server/src/embedding/remote-runtime.ts +21 -0
- package/template/apps/server/src/embedding/run-fingerprint.test.ts +26 -0
- package/template/apps/server/src/embedding/run-fingerprint.ts +11 -2
- package/template/apps/server/src/module-host/ai-operation-port.test.ts +72 -0
- package/template/apps/server/src/module-host/ai-operation-port.ts +43 -0
- package/template/apps/server/src/module-host/ai-operation-speech-normalize.test.ts +186 -0
- package/template/docker-compose.yml +11 -0
- package/template/docs/architecture/ai-runtime-readiness.md +6 -0
- package/template/docs/platform/vision-ai-runtime.md +22 -1
- package/template/packages/config/package.json +1 -1
- package/template/packages/database/package.json +1 -1
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/modules/index.ts +1 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/ai-operation-composition.ts +2 -0
- package/template/packages/modules/src/ai-screenshot-context.test.ts +72 -0
- package/template/packages/modules/src/ai-screenshot-context.ts +50 -0
- package/template/packages/modules/src/manifest.ts +8 -0
- package/template/packages/server-sdk/package.json +1 -1
- package/template/packages/shared/index.ts +3 -0
- package/template/packages/shared/package.json +1 -1
- package/template/packages/shared/src/screenshot-context-digest.test.ts +46 -0
- package/template/packages/shared/src/screenshot-context-digest.ts +72 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from southwind_embedding_server.config import EmbeddingConfig
|
|
9
|
+
from southwind_embedding_server.contracts import EmbeddingRequest
|
|
10
|
+
from southwind_embedding_server.pin_cli import compute_pins
|
|
11
|
+
from southwind_embedding_server.readiness import EmbeddingReadinessProbe
|
|
12
|
+
from southwind_embedding_server.readiness_contracts import (
|
|
13
|
+
AdapterEmbeddingReadinessV1,
|
|
14
|
+
compute_offers_digest,
|
|
15
|
+
compute_release_digest,
|
|
16
|
+
final_offers,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
IDENTITY = "windy-deployment-v1_0f26eb34-f384-4ec9-98f3-caf8d582291f"
|
|
20
|
+
ADAPTER_IDENTITY = "windy-deployment-v1_11c93162-e5f0-40af-93ce-9771a4bf12ed"
|
|
21
|
+
DIGEST_A = f"sha256:{'a' * 64}"
|
|
22
|
+
DIGEST_B = f"sha256:{'b' * 64}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def adapter_payload() -> dict[str, object]:
|
|
26
|
+
return {
|
|
27
|
+
"contractVersion": 1,
|
|
28
|
+
"status": "ready",
|
|
29
|
+
"runtimeKind": "embedding-adapter",
|
|
30
|
+
"deployment": {
|
|
31
|
+
"identity": ADAPTER_IDENTITY,
|
|
32
|
+
"releaseDigest": DIGEST_A,
|
|
33
|
+
"model": "embedding-model",
|
|
34
|
+
"version": "2026-08",
|
|
35
|
+
"dimensions": 3,
|
|
36
|
+
"normalized": True,
|
|
37
|
+
},
|
|
38
|
+
"offers": [
|
|
39
|
+
{
|
|
40
|
+
"variant": "text",
|
|
41
|
+
"capabilityVersion": "embedding.text/v1",
|
|
42
|
+
"capabilities": ["embedding.text"],
|
|
43
|
+
"inputModalities": ["text"],
|
|
44
|
+
"outputModalities": ["embedding"],
|
|
45
|
+
"limits": limits(),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"variant": "image",
|
|
49
|
+
"capabilityVersion": "embedding.image/v1",
|
|
50
|
+
"capabilities": ["embedding.image"],
|
|
51
|
+
"inputModalities": ["image"],
|
|
52
|
+
"outputModalities": ["embedding"],
|
|
53
|
+
"limits": {
|
|
54
|
+
**limits(),
|
|
55
|
+
"maxArtifacts": 8,
|
|
56
|
+
"maxPixelsPerArtifact": 20_000_000,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def limits() -> dict[str, int]:
|
|
64
|
+
return {
|
|
65
|
+
"maxDurationSeconds": 120,
|
|
66
|
+
"maxInputBytes": 64 * 1024 * 1024,
|
|
67
|
+
"maxOutputBytes": 64 * 1024 * 1024,
|
|
68
|
+
"maxUnits": 64,
|
|
69
|
+
"maxConcurrentRuns": 2,
|
|
70
|
+
"maxQueueDepth": 0,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def base_config() -> EmbeddingConfig:
|
|
75
|
+
return EmbeddingConfig(
|
|
76
|
+
internal_token="internal",
|
|
77
|
+
artifact_url="http://server.internal/api/internal/ai/artifacts",
|
|
78
|
+
run_artifact_url="http://server.internal/api/internal/embeddings/runs",
|
|
79
|
+
adapter_url="http://adapter.internal",
|
|
80
|
+
adapter_token="adapter-token",
|
|
81
|
+
deployment_key="embedding.primary",
|
|
82
|
+
deployment_identity=IDENTITY,
|
|
83
|
+
release_digest=DIGEST_A,
|
|
84
|
+
offers_digest=DIGEST_B,
|
|
85
|
+
deployment_alias="intranet-v1",
|
|
86
|
+
model="embedding-model",
|
|
87
|
+
version="2026-08",
|
|
88
|
+
dimensions=3,
|
|
89
|
+
normalized=True,
|
|
90
|
+
capabilities=("embedding.text", "embedding.image"),
|
|
91
|
+
max_concurrent_runs=1,
|
|
92
|
+
max_queue_depth=0,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def pinned_config() -> EmbeddingConfig:
|
|
97
|
+
config = base_config()
|
|
98
|
+
adapter = AdapterEmbeddingReadinessV1.model_validate(adapter_payload())
|
|
99
|
+
return replace(
|
|
100
|
+
config,
|
|
101
|
+
release_digest=compute_release_digest(adapter),
|
|
102
|
+
offers_digest=compute_offers_digest(final_offers(config, adapter)),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def transport(status: int = 200) -> httpx.MockTransport:
|
|
107
|
+
return httpx.MockTransport(
|
|
108
|
+
lambda _request: httpx.Response(status, json=adapter_payload())
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def probe(config: EmbeddingConfig, status: int = 200) -> EmbeddingReadinessProbe:
|
|
113
|
+
return EmbeddingReadinessProbe(
|
|
114
|
+
config,
|
|
115
|
+
transport(status),
|
|
116
|
+
wall_clock=lambda: datetime(2026, 8, 6, 1, 2, 3, 4000, tzinfo=UTC),
|
|
117
|
+
monotonic_clock=lambda: 10.0,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def test_readiness_attests_bounded_atomic_offers_and_pins() -> None:
|
|
122
|
+
config = pinned_config()
|
|
123
|
+
inspection = await probe(config).inspect()
|
|
124
|
+
|
|
125
|
+
assert inspection.ready
|
|
126
|
+
assert inspection.snapshot["runtimeKind"] == "embedding"
|
|
127
|
+
assert [offer["variant"] for offer in inspection.offers] == ["text", "image"]
|
|
128
|
+
deployment = inspection.snapshot["deployment"]
|
|
129
|
+
assert isinstance(deployment, dict)
|
|
130
|
+
assert deployment == {
|
|
131
|
+
"key": config.deployment_key,
|
|
132
|
+
"identity": config.deployment_identity,
|
|
133
|
+
"releaseDigest": config.release_digest,
|
|
134
|
+
"offersDigest": config.offers_digest,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def test_unconfigured_or_failed_downstream_never_becomes_ready() -> None:
|
|
139
|
+
missing = await probe(replace(base_config(), adapter_url=None)).inspect()
|
|
140
|
+
assert not missing.ready
|
|
141
|
+
assert missing.snapshot["configured"] is False
|
|
142
|
+
|
|
143
|
+
failed = await probe(pinned_config(), 503).inspect()
|
|
144
|
+
assert not failed.ready
|
|
145
|
+
downstream = failed.snapshot["downstreamProbe"]
|
|
146
|
+
assert isinstance(downstream, dict)
|
|
147
|
+
assert downstream["failureCode"] == "RESOURCE_UNAVAILABLE"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def test_request_requires_exact_deployment_pin_and_matching_offer() -> None:
|
|
151
|
+
readiness = probe(pinned_config())
|
|
152
|
+
inspection = await readiness.inspect()
|
|
153
|
+
request = embedding_request(inspection.snapshot["deployment"])
|
|
154
|
+
|
|
155
|
+
assert readiness.supports(inspection, request)
|
|
156
|
+
assert request.readiness_pin is not None
|
|
157
|
+
drifted = request.model_copy(
|
|
158
|
+
update={
|
|
159
|
+
"readiness_pin": request.readiness_pin.model_copy(
|
|
160
|
+
update={"offers_digest": DIGEST_A}
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
assert not readiness.supports(inspection, drifted)
|
|
165
|
+
assert readiness.supports(
|
|
166
|
+
inspection, request.model_copy(update={"readiness_pin": None})
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def test_pin_cli_uses_the_same_real_probe_facts() -> None:
|
|
171
|
+
config = base_config()
|
|
172
|
+
pins = await compute_pins(config, transport())
|
|
173
|
+
expected = pinned_config()
|
|
174
|
+
assert pins == {
|
|
175
|
+
"deploymentKey": config.deployment_key,
|
|
176
|
+
"deploymentIdentity": config.deployment_identity,
|
|
177
|
+
"releaseDigest": expected.release_digest,
|
|
178
|
+
"offersDigest": expected.offers_digest,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def embedding_request(deployment: object) -> EmbeddingRequest:
|
|
183
|
+
assert isinstance(deployment, dict)
|
|
184
|
+
return EmbeddingRequest.model_validate(
|
|
185
|
+
{
|
|
186
|
+
"kind": "embedding",
|
|
187
|
+
"operationKey": "analysis.embedding.text",
|
|
188
|
+
"items": [{"id": "one", "text": "安全文本"}],
|
|
189
|
+
"options": {},
|
|
190
|
+
"inputModalities": ["text"],
|
|
191
|
+
"requiredCapabilities": ["embedding.text"],
|
|
192
|
+
"dataClassification": "sensitive",
|
|
193
|
+
"limits": {
|
|
194
|
+
"maxDurationSeconds": 30,
|
|
195
|
+
"maxInputBytes": 1024,
|
|
196
|
+
"maxOutputBytes": 1024,
|
|
197
|
+
"maxUnits": 10,
|
|
198
|
+
},
|
|
199
|
+
"providerEgress": {"mode": "forbidden"},
|
|
200
|
+
"readinessPin": deployment,
|
|
201
|
+
}
|
|
202
|
+
)
|
|
@@ -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=
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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:
|
package/template/apps/server/src/agent/drizzle-screenshot-intake-repository.integration.test.ts
CHANGED
|
@@ -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");
|
|
@@ -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
|
+
}
|