@fabrika/provider-zerops 0.0.25 → 0.0.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.0.25",
3
+ "version": "0.0.26",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,9 +32,9 @@
32
32
  "gen:schema": "bun scripts/generate-zerops-types.ts"
33
33
  },
34
34
  "dependencies": {
35
- "@fabrika/auth": "^0.0.25",
36
- "@fabrika/auth-core": "^0.0.25",
37
- "@fabrika/provider-contract": "^0.0.25",
38
- "@fabrika/proxy-contract": "^0.0.25"
35
+ "@fabrika/auth": "^0.0.26",
36
+ "@fabrika/auth-core": "^0.0.26",
37
+ "@fabrika/provider-contract": "^0.0.26",
38
+ "@fabrika/proxy-contract": "^0.0.26"
39
39
  }
40
40
  }
package/src/api.ts CHANGED
@@ -258,7 +258,7 @@ export interface ZeropsServiceEnv {
258
258
 
259
259
  /**
260
260
  * A short-lived grant for the project's LOG service. VERIFIED: `ResponseProjectLog` — the field names and
261
- * types are exactly these. What is NOT verified is what the URLs speak; see `readBuildLog`.
261
+ * types are exactly these. What the URLs speak was measured separately; see `readBuildLog`.
262
262
  */
263
263
  export interface ZeropsLogAccess {
264
264
  url: string
@@ -271,10 +271,14 @@ export interface ZeropsLogAccess {
271
271
  expiration: string
272
272
  }
273
273
 
274
+ /** The RFC3339 stamp the plaintext log window puts first on every line. Anchored so a bare year cannot pass. */
275
+ const LOG_LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
276
+
274
277
  /** One relayed build/runtime log line. Shape is fabrika's, not the platform's. */
275
278
  export interface ZeropsLogLine {
276
- /** ISO timestamp when Zerops recorded the line, when it supplied one. */
279
+ /** RFC3339 stamp Zerops put at the head of the line, when the line carried one. */
277
280
  timestamp?: string
281
+ /** The line as the log service wrote it, timestamp and tag included. */
278
282
  message: string
279
283
  }
280
284
 
@@ -457,8 +461,9 @@ export interface ZeropsApi {
457
461
  * Every environment variable of ONE service, each with the record id `deleteServiceEnv` takes.
458
462
  * VERIFIED: `GET /service-stack/{id}/env` (`{ items }`).
459
463
  *
460
- * NOT `GET /service-stack/{id}/user-data`, which answers `400 serviceStackNotFound` on every
461
- * service, deployed or not — see `docs/reference/zerops-platform.md`. The `/env` reading omits
464
+ * NOT `GET /service-stack/{id}/user-data`: it answers a list now (2026-08-21), but `/env` is the
465
+ * endpoint whose record ids `PUT`/`DELETE /user-data/{id}` take, and the list's paging terms were
466
+ * never verified — see `docs/reference/zerops-platform.md`. The `/env` reading omits
462
467
  * variables the service's own `zerops.yaml` declares (`type: ENV`); fabrika never writes those.
463
468
  * The OpenAPI publishes no lookup/pagination terms and live probes found query terms ignored, so the
464
469
  * implementation reads one full response under a byte bound rather than imposing an entry-count cap.
@@ -548,17 +553,42 @@ export interface ZeropsApi {
548
553
  getLogAccess(input: { projectId: string; signal: AbortSignal }): Promise<ZeropsLogAccess>
549
554
 
550
555
  /**
551
- * UNVERIFIED — THE ONE SHAPE NOBODY COULD CHECK. `GET /project/{id}/log` hands back URLs to a
552
- * SEPARATE log service, and that service's request/response contract appears in no published document
553
- * (it is not in the OpenAPI file, which stops at the grant). The default implementation's reading is:
554
- * GET `urlPlain` with the grant as a bearer, one plain-text line per log record, `limit`/`from` as
555
- * query params. Treat that as a guess.
556
+ * One window of the project's log, narrowed to ONE app version.
557
+ *
558
+ * VERIFIED 2026-08-21 by measurement, not by document: the OpenAPI file stops at the grant, so the
559
+ * separate log service was read against a live project. `urlPlain` answers one
560
+ * `<RFC3339 timestamp> <tag> <message>` line per record and accepts `serviceStackId`, `limit`, `tags`
561
+ * and `desc`; `from` and unknown terms are accepted and silently ignored, so nothing may depend on them.
562
+ *
563
+ * ORDERING, also measured: `limit=N` without `desc` returns the NEWEST N records in ASCENDING timestamp
564
+ * order (`limit=3` against a log starting 15:50 answered 17:00:00, 17:00:00, 17:15:00), and `desc=1`
565
+ * returns the same newest N, newest first. So `desc` is needed neither for the `since` cut nor for a
566
+ * build longer than the limit: each poll re-reads the newest window and the caller's dedupe streams it.
556
567
  *
557
- * BECAUSE it is a guess, the driver treats a failure here as non-fatal: log relay degrades to "no
558
- * lines" and the deploy still succeeds or fails on `/app-version` status alone. An unverified endpoint
559
- * must never be able to fail a deploy.
568
+ * A version's BUILD lines carry the tag `zbuilder@<appVersionId>` and are produced on a separate
569
+ * builder stack, so they are selected by `tags` ALONE — combining that tag with the runtime
570
+ * `serviceStackId` matches nothing. RUNTIME lines carry no version marker at all and the runtime
571
+ * service's window mixes every version that ever ran, so they are selected by `serviceStackId` and cut
572
+ * at `since` (the version's `build.pipelineStart`). Without `since` no runtime line is read: a line
573
+ * from an earlier version is worse in a run log than a missing one. The cut is by TIME, not identity —
574
+ * the outgoing container keeps logging until it is replaced, so its lines stamped after `since` still
575
+ * pass, and only the earlier version's older history is excluded.
576
+ *
577
+ * The two windows are read INDEPENDENTLY: one failing must not discard what the other returned, and only
578
+ * a failure of every window attempted is raised. The driver then treats that as non-fatal anyway — log
579
+ * relay degrades to "no lines" and the deploy succeeds or fails on `/app-version` status alone. A log
580
+ * service must never be able to fail a deploy.
560
581
  */
561
- readBuildLog(input: { access: ZeropsLogAccess; serviceId: string; limit?: number; signal: AbortSignal }): Promise<ZeropsLogLine[]>
582
+ readBuildLog(input: {
583
+ access: ZeropsLogAccess
584
+ serviceId: string
585
+ /** Select this version's build lines by their `zbuilder@` tag; without it no build line is read. */
586
+ appVersionId?: string
587
+ /** RFC3339 lower bound for runtime lines, normally `build.pipelineStart`; without it none are read. */
588
+ since?: string
589
+ limit?: number
590
+ signal: AbortSignal
591
+ }): Promise<ZeropsLogLine[]>
562
592
  }
563
593
 
564
594
  // ── default (real) implementation ───────────────────────────────────────────────
@@ -1245,20 +1275,47 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
1245
1275
  }
1246
1276
  },
1247
1277
 
1248
- readBuildLog: async ({ access, serviceId, limit, signal }) => {
1249
- // UNVERIFIED (see the interface): this is a best reading of an undocumented log service, not a
1250
- // contract. It is written so a wrong guess degrades to "no lines" instead of failing a deploy.
1278
+ readBuildLog: async ({ access, serviceId, appVersionId, since, limit, signal }) => {
1251
1279
  if (access.urlPlain === '') {
1252
1280
  return []
1253
1281
  }
1254
1282
  const separator = access.urlPlain.includes('?') ? '&' : '?'
1255
- const url = `${access.urlPlain}${separator}serviceStackId=${encodeURIComponent(serviceId)}&limit=${limit ?? 200}`
1256
- const response = await doFetch(url, { headers: { authorization: `Bearer ${access.accessToken}` }, signal })
1257
- if (!response.ok) {
1258
- throw new Error(`zerops: read build log failed (${response.status})`)
1283
+ const readWindow = async (terms: string): Promise<{ lines: ZeropsLogLine[]; error: unknown }> => {
1284
+ try {
1285
+ const url = `${access.urlPlain}${separator}${terms}&limit=${limit ?? 200}`
1286
+ const response = await doFetch(url, { headers: { authorization: `Bearer ${access.accessToken}` }, signal })
1287
+ if (!response.ok) {
1288
+ throw new Error(`zerops: read build log failed (${response.status})`)
1289
+ }
1290
+ const text = await response.text()
1291
+ const lines = text.split('\n').filter((line) => line.trim() !== '').map((line) => {
1292
+ const head = line.slice(0, Math.max(line.indexOf(' '), 0))
1293
+ return LOG_LINE_TIMESTAMP.test(head) ? { timestamp: head, message: line } : { message: line }
1294
+ })
1295
+ return { lines, error: undefined }
1296
+ } catch (error) {
1297
+ return { lines: [], error }
1298
+ }
1299
+ }
1300
+ // Selected by tag alone: the build runs on a builder stack, so pairing the tag with the runtime
1301
+ // service id matches nothing (see the interface).
1302
+ const build = appVersionId === undefined ? undefined : await readWindow(`tags=${encodeURIComponent(`zbuilder@${appVersionId}`)}`)
1303
+ const runtime = since === undefined ? undefined : await readWindow(`serviceStackId=${encodeURIComponent(serviceId)}`)
1304
+ const attempted = [build, runtime].filter((result) => result !== undefined)
1305
+ if (attempted.length === 0) {
1306
+ return []
1307
+ }
1308
+ // Independent windows: only a failure of every one attempted is worth raising.
1309
+ const failure = attempted.find((result) => result.error !== undefined)
1310
+ if (failure !== undefined && attempted.every((result) => result.error !== undefined)) {
1311
+ throw failure.error
1259
1312
  }
1260
- const text = await response.text()
1261
- return text.split('\n').filter((line) => line.trim() !== '').map((line) => ({ message: line }))
1313
+ const boundary = since === undefined ? Number.NaN : Date.parse(since)
1314
+ // The runtime window carries every version that ever ran, so a line the driver cannot date is dropped.
1315
+ const runtimeLines = runtime === undefined || Number.isNaN(boundary)
1316
+ ? runtime?.lines ?? []
1317
+ : runtime.lines.filter((line) => line.timestamp !== undefined && Date.parse(line.timestamp) >= boundary)
1318
+ return [...build?.lines ?? [], ...runtimeLines]
1262
1319
  },
1263
1320
  }
1264
1321
  }
package/src/namespace.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type ProviderNamespaceCapabilities,
7
7
  ProviderNamespaceError,
8
8
  type ProviderNamespaceFact,
9
+ type ProviderNamespaceHost,
9
10
  type ProviderNamespaceMutationInput,
10
11
  type ProviderNamespaceOperator,
11
12
  type ProviderNamespacePlanInput,
@@ -35,6 +36,8 @@ export const ZEROPS_NAMESPACE_PROXY_HOSTNAME = 'proxy'
35
36
  export const ZEROPS_NAMESPACE_POSTGRES_HOSTNAME = 'postgres'
36
37
  export const ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE = 'FABRIKA_IAM_ISSUER'
37
38
  export const ZEROPS_NAMESPACE_IAM_KEY_VARIABLE = 'FABRIKA_IAM_KEY'
39
+ /** The platform-owned variable naming one URL per published HTTP port. READ_ONLY, written by Zerops. */
40
+ export const ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE = 'zeropsSubdomain'
38
41
  /**
39
42
  * How an app reaches the namespace-owned PostgreSQL service — port, database and TLS mode NAMED, not
40
43
  * defaulted. Every part settled against a live account, none of it safe to infer:
@@ -91,6 +94,8 @@ export interface ZeropsNamespaceTarget {
91
94
  readonly proxyConfigured?: boolean
92
95
  readonly proxyBaselineSequence?: number
93
96
  readonly proxyAppVersionId?: string
97
+ /** The hosts the published proxy answers on, recorded once so a namespace read needs no platform call. */
98
+ readonly proxyHosts?: readonly ProviderNamespaceHost[]
94
99
  readonly ready?: boolean
95
100
  }
96
101
 
@@ -221,6 +226,54 @@ const parsePostgres = (payload: JsonValue): ZeropsNamespacePostgres | undefined
221
226
  return profile === undefined ? { type } : { type, profile }
222
227
  }
223
228
 
229
+ const parseProxyHosts = (payload: JsonValue): readonly ProviderNamespaceHost[] | undefined => {
230
+ const value = property(payload, 'proxyHosts')
231
+ if (value === undefined) return undefined
232
+ if (!Array.isArray(value)) {
233
+ throw new Error('Zerops namespace target proxyHosts must be an array')
234
+ }
235
+ return value.map((entry) => {
236
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
237
+ throw new Error('Zerops namespace target proxyHosts entry must be an object')
238
+ }
239
+ const host = property(entry, 'host')
240
+ const port = property(entry, 'port')
241
+ if (typeof host !== 'string' || host === '') {
242
+ throw new Error('Zerops namespace target proxyHosts host must be a non-empty string')
243
+ }
244
+ if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0) {
245
+ throw new Error('Zerops namespace target proxyHosts port must be a positive integer')
246
+ }
247
+ return { host, port }
248
+ })
249
+ }
250
+
251
+ /**
252
+ * One host per published HTTP port, out of the newline-separated URLs the platform writes.
253
+ *
254
+ * The form is `<hostname>-<hash>-<port>.<region>.zerops.app` (e.g. `proxy-2ec8-8080`). Read as URLs
255
+ * rather than split on `-`, so a hostname whose own first label ends in digits cannot be misread; a line
256
+ * that is not a URL, or whose first label carries no trailing port — the single portless line a proxy
257
+ * that has never deployed publishes — is skipped rather than guessed at.
258
+ */
259
+ const parseZeropsProxyHosts = (value: string): ProviderNamespaceHost[] => {
260
+ const hosts: ProviderNamespaceHost[] = []
261
+ for (const line of value.split('\n')) {
262
+ const trimmed = line.trim()
263
+ if (trimmed === '') continue
264
+ let hostname: string
265
+ try {
266
+ hostname = new URL(trimmed).hostname
267
+ } catch {
268
+ continue
269
+ }
270
+ const port = /-(\d+)$/.exec(hostname.split('.')[0] ?? '')?.[1]
271
+ if (port === undefined) continue
272
+ hosts.push({ host: hostname, port: Number.parseInt(port, 10) })
273
+ }
274
+ return hosts.sort((left, right) => left.port - right.port)
275
+ }
276
+
224
277
  export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> = {
225
278
  version: 1,
226
279
  encode: (target) => ({
@@ -238,6 +291,7 @@ export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> =
238
291
  ...(target.proxyConfigured !== undefined ? { proxyConfigured: target.proxyConfigured } : {}),
239
292
  ...(target.proxyBaselineSequence !== undefined ? { proxyBaselineSequence: target.proxyBaselineSequence } : {}),
240
293
  ...(target.proxyAppVersionId !== undefined ? { proxyAppVersionId: target.proxyAppVersionId } : {}),
294
+ ...(target.proxyHosts === undefined ? {} : { proxyHosts: target.proxyHosts.map((entry) => ({ host: entry.host, port: entry.port })) }),
241
295
  ...(target.ready !== undefined ? { ready: target.ready } : {}),
242
296
  }),
243
297
  decode: (payload) => {
@@ -272,6 +326,7 @@ export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> =
272
326
  ...(optionalString(payload, 'proxyAppVersionId') !== undefined
273
327
  ? { proxyAppVersionId: optionalString(payload, 'proxyAppVersionId') }
274
328
  : {}),
329
+ ...(parseProxyHosts(payload) !== undefined ? { proxyHosts: parseProxyHosts(payload) } : {}),
275
330
  ...(optionalBoolean(payload, 'ready') !== undefined ? { ready: optionalBoolean(payload, 'ready') } : {}),
276
331
  }
277
332
  },
@@ -320,6 +375,14 @@ const registrationResourceClaims = (registration: ProviderRegistration): string[
320
375
  appId: registration.app.id,
321
376
  env: registration.environment.env,
322
377
  })
378
+ // A proxy target is routed by HOST, so a registration with no domain has nothing the proxy can serve:
379
+ // refuse it here, where nothing has been created yet, instead of at the first deploy's manifest compile.
380
+ if (manifest.target.proxy !== undefined && (registration.environment.domain ?? '').trim() === '') {
381
+ throw new Error(
382
+ `Zerops proxy target \`${registration.app.id}/${registration.environment.env}\` requires a public domain: `
383
+ + `pass --domain with a host this namespace serves (\`namespaces get ${namespace.id}\` lists them)`,
384
+ )
385
+ }
323
386
  const namespaceClaims = new Set(namespaceResourceClaims(namespace))
324
387
  for (const requirement of manifest.target.namespaceResources ?? []) {
325
388
  if (!namespaceClaims.has(requirement.resourceKey)) {
@@ -503,10 +566,14 @@ const namespacePresentation = (
503
566
  const postgres = target.postgres === undefined
504
567
  ? 'App-owned database services'
505
568
  : `Shared ${target.postgres.type}${target.postgres.profile === undefined ? '' : ` (${target.postgres.profile})`}`
569
+ // Only a published subdomain namespace knows its own hosts; custom domains are bound out of band, so
570
+ // the provider names none and an operator reads them where they were bound.
571
+ const hosts = publicAccess === 'zerops-subdomain' && target.ready === true ? target.proxyHosts ?? [] : []
506
572
  const instructions = [
507
573
  ...(publicAccess === 'custom-domain'
508
574
  ? [`Bind each application domain to the \`proxy\` service in Zerops project \`${projectName}\`; keep every app service private.`]
509
575
  : ['Use the proxy Zerops subdomain only for non-production access; app services must remain private.']),
576
+ ...(hosts.length === 0 ? [] : ['Register a proxy-target environment with one of the hosts below as its domain; each host serves one app.']),
510
577
  ...(target.postgres === undefined
511
578
  ? []
512
579
  : ['Every app consuming the shared PostgreSQL binding shares its physical service and provider-issued credential.']),
@@ -528,6 +595,7 @@ const namespacePresentation = (
528
595
  { label: 'Public access', value: publicAccess },
529
596
  ],
530
597
  instructions,
598
+ ...(hosts.length === 0 ? {} : { hosts }),
531
599
  }
532
600
  }
533
601
 
@@ -832,6 +900,38 @@ const ensureProxyConfiguration = async (
832
900
  return deployRequired
833
901
  }
834
902
 
903
+ /**
904
+ * The hosts the proxy publishes, off the platform-owned `zeropsSubdomain` variable.
905
+ *
906
+ * Read through the same `/env` listing every other namespace variable uses, and read AFTER the proxy is
907
+ * published: the variable names a host per DEPLOYED HTTP port, so a proxy that never deployed names none.
908
+ * Recording the answer in the target is what keeps `namespaces get` a database read.
909
+ */
910
+ const readProxyHosts = async (
911
+ namespace: ProviderDeploymentNamespace,
912
+ target: NormalizedZeropsNamespaceTarget,
913
+ options: ZeropsNamespaceOptions,
914
+ signal: AbortSignal,
915
+ ): Promise<readonly ProviderNamespaceHost[]> => {
916
+ const proxyServiceId = target.proxyServiceId
917
+ if (proxyServiceId === undefined) {
918
+ throw new Error('Zerops namespace has no proxy service id')
919
+ }
920
+ try {
921
+ const variables = await options.api.listServiceEnv({ serviceId: proxyServiceId, signal })
922
+ const subdomains = variables.find((variable) => variable.key === ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE)
923
+ return subdomains === undefined ? [] : parseZeropsProxyHosts(subdomains.content)
924
+ } catch {
925
+ // A host listing is a convenience; the proxy is already built, deployed and published. Failing the
926
+ // namespace over it would report a placement that WORKS as broken. Never the cause: it can carry
927
+ // upstream text this provider has not redacted.
928
+ console.warn(
929
+ `Zerops namespace ${namespace.id}: reading the published proxy hosts failed; the placement is ready and lists none until its next reconcile`,
930
+ )
931
+ return []
932
+ }
933
+ }
934
+
835
935
  const latestAfterBaseline = async (
836
936
  target: NormalizedZeropsNamespaceTarget,
837
937
  options: ZeropsNamespaceOptions,
@@ -1125,10 +1225,14 @@ const runNamespaceMutation = async (
1125
1225
  if (target.publicAccess === 'zerops-subdomain') {
1126
1226
  await ensureSubdomainAccess(target, options, input.signal)
1127
1227
  }
1228
+ const proxyHosts = target.publicAccess === 'zerops-subdomain' ? await readProxyHosts(namespace, target, options, input.signal) : []
1128
1229
  target = {
1129
1230
  ...target,
1130
1231
  proxyBaselineSequence: undefined,
1131
1232
  proxyAppVersionId: undefined,
1233
+ // Cleared when the proxy publishes none and on a custom-domain namespace, so the record never
1234
+ // outlives what it describes.
1235
+ proxyHosts: proxyHosts.length === 0 ? undefined : proxyHosts,
1132
1236
  ready: true,
1133
1237
  }
1134
1238
  return checkpoint(input, namespace, target)
package/src/provider.ts CHANGED
@@ -170,23 +170,41 @@ const cleanupPreTriggerVersion = async (env: StepEnv, source: ZeropsRuntimeSourc
170
170
  }
171
171
 
172
172
  /**
173
- * Relay whatever build log Zerops will give us into the run's progress sink.
173
+ * Relay ONE app version's build log into the run's progress sink.
174
174
  *
175
- * Deliberately FAILURE-TOLERANT: the log service's protocol is the one shape in this driver nobody could
176
- * verify against a document (see `ZeropsApi.readBuildLog`). An unverified endpoint must never be able to
177
- * fail a deploy, so a broken guess degrades to "no lines relayed" and the run still succeeds or fails on
178
- * the platform's process and app-version statuses.
175
+ * Scoped to the version being deployed: the runtime service's log window mixes every version that ever ran
176
+ * on it, so an unscoped read stamps a run's log with lines from earlier releases. `appVersionId` selects
177
+ * the build lines and `since` — the version's pipeline start — cuts the runtime ones
178
+ * (see `ZeropsApi.readBuildLog`). Before the platform reports a pipeline start there is nothing to cut
179
+ * against, so no runtime line is relayed yet. The cut is by time, so the OUTGOING container's own lines
180
+ * still arrive until it is replaced; what it removes is every earlier version's history.
181
+ *
182
+ * Deliberately FAILURE-TOLERANT: a log service must never be able to fail a deploy, so an error degrades
183
+ * to "no lines relayed" and the run still succeeds or fails on the platform's process and app-version
184
+ * statuses.
179
185
  *
180
186
  * Relay is pull-based (ADR-0003), so each poll re-reads a window that overlaps the last one; `seen` keeps
181
187
  * the run log from repeating itself. Two genuinely identical lines with no timestamp collapse into one —
182
188
  * an acceptable trade for not printing the same window every three seconds.
183
189
  */
184
- const relayLog = async (env: StepEnv, access: ZeropsLogAccess | null, seen: Set<string>): Promise<void> => {
190
+ const relayLog = async (
191
+ env: StepEnv,
192
+ access: ZeropsLogAccess | null,
193
+ seen: Set<string>,
194
+ appVersionId: string,
195
+ since: string | undefined,
196
+ ): Promise<void> => {
185
197
  if (access === null) {
186
198
  return
187
199
  }
188
200
  try {
189
- const lines = await env.zerops.api.readBuildLog({ access, serviceId: env.run.target.serviceId, signal: env.signal })
201
+ const lines = await env.zerops.api.readBuildLog({
202
+ access,
203
+ serviceId: env.run.target.serviceId,
204
+ appVersionId,
205
+ ...(since === undefined ? {} : { since }),
206
+ signal: env.signal,
207
+ })
190
208
  for (const line of lines) {
191
209
  const key = `${line.timestamp ?? ''}\u0000${line.message}`
192
210
  if (seen.has(key)) {
@@ -248,7 +266,7 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
248
266
  log(` ${appVersionId}: ${version.status ?? 'unknown'}`)
249
267
  previous = version.status
250
268
  }
251
- await relayLog(env, access, seen)
269
+ await relayLog(env, access, seen, appVersionId, version.build?.pipelineStart)
252
270
 
253
271
  if (process?.status !== undefined && ZEROPS_PROCESS_TERMINAL.has(process.status) && process.status !== ZEROPS_PROCESS_FINISHED) {
254
272
  throw new Error(