@sanity/ailf 6.1.2 → 7.0.0

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.
@@ -10,21 +10,25 @@
10
10
  * Designed to run in any HTTP environment: Cloudflare Workers, Vercel
11
11
  * functions, Express, Hono, etc.
12
12
  *
13
- * Supports two scoping modes:
14
- * - **Release-scoped** requires `perspective` field
15
- * - **Task-scoped** requires `tasks` array (optionally with `areas`)
16
- *
17
- * At least one of `perspective` or `tasks` must be present.
13
+ * The eval-request document carries a canonical `PipelineRequest` JSON
14
+ * blob in its `pipelineRequest` field (see W0239). The handler parses it
15
+ * via `PipelineRequestSchema` from `@sanity/ailf-core` and forwards it
16
+ * to the dispatcher as-is. Scoping (release-scoped via `perspective`,
17
+ * task-scoped via `tasks`) is asserted on the parsed `PipelineRequest`
18
+ * — at least one must be present.
18
19
  *
19
20
  * Flow:
20
21
  * 1. Receive eval request payload (from Sanity webhook projection)
21
- * 2. Validate: must be `ailf.evalRequest` type, `pending` status,
22
- * with either `perspective` or `tasks`
23
- * 3. Dispatch evaluation to GitHub Actions via `repository_dispatch`
24
- * with `external-eval` event type and scoped client payload
25
- * 4. On success: PATCH the eval request document → `status: "dispatched"`
26
- * 5. On failure: PATCH the eval request document → `status: "failed"` + error
27
- * 6. Return a structured result
22
+ * 2. Validate envelope: must be `ailf.evalRequest` type, `pending` status,
23
+ * `pipelineRequest` present
24
+ * 3. Parse + Zod-validate `pipelineRequest` against `PipelineRequestSchema`
25
+ * 4. Assert scoping: parsed request must have `perspective` or `tasks`
26
+ * 5. Dispatch evaluation to GitHub Actions via `repository_dispatch`
27
+ * with `external-eval` event type the parsed `PipelineRequest`
28
+ * rides as `client_payload.request` unchanged
29
+ * 6. On success: PATCH the eval request document → `status: "dispatched"`
30
+ * 7. On failure: PATCH the eval request document → `status: "failed"` + error
31
+ * 8. Return a structured result
28
32
  *
29
33
  * ## Sanity Manage Webhook Configuration
30
34
  *
@@ -45,6 +49,7 @@
45
49
  * @see docs/design-docs/report-store/visibility-workflows.md
46
50
  */
47
51
  import { createClient } from "@sanity/client";
52
+ import { PipelineRequestSchema } from "../_vendor/ailf-core/index.js";
48
53
  // ---------------------------------------------------------------------------
49
54
  // Constants
50
55
  // ---------------------------------------------------------------------------
@@ -116,18 +121,33 @@ export async function handleEvalRequest(payload, config) {
116
121
  requestId,
117
122
  };
118
123
  }
119
- const hasPerspective = !!payload.perspective;
120
- const hasTasks = Array.isArray(payload.tasks) && payload.tasks.length > 0;
124
+ if (!payload.pipelineRequest) {
125
+ return markFailed("Missing required field: pipelineRequest. The eval-request document " +
126
+ "must carry a canonical PipelineRequest JSON serialization.");
127
+ }
128
+ let parsedRequest;
129
+ try {
130
+ parsedRequest = JSON.parse(payload.pipelineRequest);
131
+ }
132
+ catch (err) {
133
+ return markFailed(`pipelineRequest is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
134
+ }
135
+ const parseResult = PipelineRequestSchema.safeParse(parsedRequest);
136
+ if (!parseResult.success) {
137
+ return markFailed(`pipelineRequest failed PipelineRequestSchema validation: ${parseResult.error.message}`);
138
+ }
139
+ const request = reconcileCallerIdentity(parseResult.data, payload.requestedBy);
140
+ const hasPerspective = !!request.perspective;
141
+ const hasTasks = Array.isArray(request.tasks) && request.tasks.length > 0;
121
142
  if (!hasPerspective && !hasTasks) {
122
- return markFailed("Missing required field: perspective or tasks. " +
123
- "Provide a content release perspective for release evals, " +
124
- "or a tasks array for task-scoped evals.");
143
+ return markFailed("pipelineRequest must scope the evaluation: provide either " +
144
+ "`perspective` (release-scoped) or `tasks` (task-scoped).");
125
145
  }
126
146
  // -------------------------------------------------------------------------
127
147
  // 3. Dispatch evaluation via GitHub Actions
128
148
  // -------------------------------------------------------------------------
129
149
  const repo = config.githubRepo ?? DEFAULT_REPO;
130
- const dispatchResult = await dispatchGitHubEval(repo, payload, config);
150
+ const dispatchResult = await dispatchGitHubEval(repo, request, config);
131
151
  // -------------------------------------------------------------------------
132
152
  // 4. Update eval request document status
133
153
  // -------------------------------------------------------------------------
@@ -152,46 +172,66 @@ export async function handleEvalRequest(payload, config) {
152
172
  // Dispatch failed — mark the document as failed
153
173
  return markFailed(dispatchResult.error ?? "Unknown dispatch error");
154
174
  }
175
+ // ---------------------------------------------------------------------------
176
+ // Internal helpers
177
+ // ---------------------------------------------------------------------------
178
+ /**
179
+ * Reconcile caller-claimed identity against the trustworthy Sanity write
180
+ * context.
181
+ *
182
+ * The `pipelineRequest` blob is authored by whoever wrote the Sanity
183
+ * document — a browser writer (App SDK dashboard) can set
184
+ * `executor.name` / `owner.individual` to any string, including
185
+ * someone else's. The webhook's only trustworthy identity signal is
186
+ * `payload.requestedBy` (the Sanity-session-authenticated writer).
187
+ *
188
+ * Per D0037, `owner.team` is caller-supplied (the caller knows their
189
+ * team); `executor.surface` / `executor.type` are caller-supplied.
190
+ * Identity fields (`executor.name`, `executor.githubActor`,
191
+ * `owner.individual`) are overwritten or stripped server-side here so
192
+ * downstream provenance reflects who actually wrote the document, not
193
+ * what they claimed.
194
+ *
195
+ * When `requestedBy` is missing (legacy documents), the executor/owner
196
+ * identity fields are stripped — the pipeline's server-side detection
197
+ * fills them as best it can.
198
+ */
199
+ function reconcileCallerIdentity(request, requestedBy) {
200
+ const out = { ...request };
201
+ if (request.executor) {
202
+ out.executor = {
203
+ ...request.executor,
204
+ ...(requestedBy ? { name: requestedBy } : { name: undefined }),
205
+ githubActor: undefined,
206
+ };
207
+ }
208
+ if (request.owner) {
209
+ out.owner = {
210
+ ...request.owner,
211
+ ...(requestedBy
212
+ ? { individual: requestedBy }
213
+ : { individual: undefined }),
214
+ };
215
+ }
216
+ return out;
217
+ }
155
218
  /**
156
219
  * Dispatch an evaluation via GitHub Actions repository_dispatch.
157
220
  *
158
- * Supports both release-scoped (perspective) and task-scoped (tasks/areas)
159
- * evaluations. Uses the `external-eval` event type with a client_payload
160
- * conforming to PipelineRequestSchema. The workflow passes it directly to
161
- * the CLI via `--config` without field translation.
221
+ * Forwards the already-validated `PipelineRequest` as-is under
222
+ * `client_payload.request` no field translation, no hardcoded
223
+ * overrides. The workflow passes the request to the CLI via `--config`.
224
+ *
225
+ * Workflow-level metadata (`caller_repo`) stays at the top level of
226
+ * `client_payload` for the workflow to read, separate from the
227
+ * pipeline-invocation contract.
162
228
  */
163
- async function dispatchGitHubEval(repo, payload, config) {
229
+ async function dispatchGitHubEval(repo, request, config) {
164
230
  const url = `${GITHUB_API}/repos/${repo}/dispatches`;
165
- const hasPerspective = !!payload.perspective;
166
- const hasTasks = Array.isArray(payload.tasks) && payload.tasks.length > 0;
167
- const hasAreas = Array.isArray(payload.areas) && payload.areas.length > 0;
168
- // Nest the PipelineRequest under `request` to stay within GitHub's
169
- // 10-property limit on client_payload. Workflow-level metadata
170
- // (caller_repo) stays at the top level for the workflow to read.
171
231
  const body = {
172
232
  client_payload: {
173
233
  caller_repo: "sanity-io/www-sanity-io",
174
- request: {
175
- dataset: payload.dataset,
176
- mode: payload.mode,
177
- projectId: payload.projectId,
178
- publish: true,
179
- source: "production",
180
- // Studio-initiated evals always use Content Lake as the task source.
181
- // Without this, the pipeline only loads filesystem .task.ts files and
182
- // Studio-owned tasks are invisible.
183
- taskMode: "content-lake",
184
- // Release-scoped fields
185
- ...(hasPerspective ? { perspective: payload.perspective } : {}),
186
- // Task-scoped fields
187
- ...(hasTasks ? { tasks: payload.tasks } : {}),
188
- ...(hasAreas ? { areas: payload.areas } : {}),
189
- ...(payload.debug ? { debug: true } : {}),
190
- ...(payload.tag ? { publishTag: payload.tag } : {}),
191
- ...(payload.sourceReportId
192
- ? { sourceReportId: payload.sourceReportId }
193
- : {}),
194
- },
234
+ request,
195
235
  },
196
236
  event_type: "external-eval",
197
237
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/ailf",
3
- "version": "6.1.2",
3
+ "version": "7.0.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -56,8 +56,8 @@
56
56
  "tsx": "^4.19.2",
57
57
  "typescript": "^5.7.3",
58
58
  "vitest": "^4.1.5",
59
- "@sanity/ailf-core": "0.1.0",
60
- "@sanity/ailf-shared": "0.1.0"
59
+ "@sanity/ailf-shared": "0.1.0",
60
+ "@sanity/ailf-core": "0.1.0"
61
61
  },
62
62
  "scripts": {
63
63
  "build": "tsc && tsc -p tsconfig.scripts.json && tsx scripts/bundle-workspace-deps.ts",