@shipfox/api-logs 2.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.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +179 -0
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/api/object-storage.d.ts +69 -0
- package/dist/api/object-storage.d.ts.map +1 -0
- package/dist/api/object-storage.js +199 -0
- package/dist/api/object-storage.js.map +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +108 -0
- package/dist/config.js.map +1 -0
- package/dist/core/append-logs.d.ts +28 -0
- package/dist/core/append-logs.d.ts.map +1 -0
- package/dist/core/append-logs.js +318 -0
- package/dist/core/append-logs.js.map +1 -0
- package/dist/core/budget.d.ts +12 -0
- package/dist/core/budget.d.ts.map +1 -0
- package/dist/core/budget.js +9 -0
- package/dist/core/budget.js.map +1 -0
- package/dist/core/close-stream.d.ts +27 -0
- package/dist/core/close-stream.d.ts.map +1 -0
- package/dist/core/close-stream.js +59 -0
- package/dist/core/close-stream.js.map +1 -0
- package/dist/core/compaction.d.ts +28 -0
- package/dist/core/compaction.d.ts.map +1 -0
- package/dist/core/compaction.js +49 -0
- package/dist/core/compaction.js.map +1 -0
- package/dist/core/entities/attempt-stream.d.ts +37 -0
- package/dist/core/entities/attempt-stream.d.ts.map +1 -0
- package/dist/core/entities/attempt-stream.js +15 -0
- package/dist/core/entities/attempt-stream.js.map +1 -0
- package/dist/core/entities/job-accounting.d.ts +12 -0
- package/dist/core/entities/job-accounting.d.ts.map +1 -0
- package/dist/core/entities/job-accounting.js +6 -0
- package/dist/core/entities/job-accounting.js.map +1 -0
- package/dist/core/entities/log-object.d.ts +13 -0
- package/dist/core/entities/log-object.d.ts.map +1 -0
- package/dist/core/entities/log-object.js +9 -0
- package/dist/core/entities/log-object.js.map +1 -0
- package/dist/core/errors.d.ts +26 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +31 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/finalize-attempt-stream.d.ts +22 -0
- package/dist/core/finalize-attempt-stream.d.ts.map +1 -0
- package/dist/core/finalize-attempt-stream.js +48 -0
- package/dist/core/finalize-attempt-stream.js.map +1 -0
- package/dist/core/read-logs.d.ts +33 -0
- package/dist/core/read-logs.d.ts.map +1 -0
- package/dist/core/read-logs.js +49 -0
- package/dist/core/read-logs.js.map +1 -0
- package/dist/core/reap-stale-open-streams.d.ts +27 -0
- package/dist/core/reap-stale-open-streams.d.ts.map +1 -0
- package/dist/core/reap-stale-open-streams.js +50 -0
- package/dist/core/reap-stale-open-streams.js.map +1 -0
- package/dist/core/retention.d.ts +32 -0
- package/dist/core/retention.d.ts.map +1 -0
- package/dist/core/retention.js +148 -0
- package/dist/core/retention.js.map +1 -0
- package/dist/core/session/claude/rows.d.ts +6 -0
- package/dist/core/session/claude/rows.d.ts.map +1 -0
- package/dist/core/session/claude/rows.js +145 -0
- package/dist/core/session/claude/rows.js.map +1 -0
- package/dist/core/session/claude-parser.d.ts +4 -0
- package/dist/core/session/claude-parser.d.ts.map +1 -0
- package/dist/core/session/claude-parser.js +42 -0
- package/dist/core/session/claude-parser.js.map +1 -0
- package/dist/core/session/entry-schema.d.ts +51 -0
- package/dist/core/session/entry-schema.d.ts.map +1 -0
- package/dist/core/session/entry-schema.js +61 -0
- package/dist/core/session/entry-schema.js.map +1 -0
- package/dist/core/session/object.d.ts +12 -0
- package/dist/core/session/object.d.ts.map +1 -0
- package/dist/core/session/object.js +50 -0
- package/dist/core/session/object.js.map +1 -0
- package/dist/core/session/parse-session.d.ts +5 -0
- package/dist/core/session/parse-session.d.ts.map +1 -0
- package/dist/core/session/parse-session.js +18 -0
- package/dist/core/session/parse-session.js.map +1 -0
- package/dist/core/session/pi/message-rows.d.ts +4 -0
- package/dist/core/session/pi/message-rows.d.ts.map +1 -0
- package/dist/core/session/pi/message-rows.js +113 -0
- package/dist/core/session/pi/message-rows.js.map +1 -0
- package/dist/core/session/pi/metadata.d.ts +18 -0
- package/dist/core/session/pi/metadata.d.ts.map +1 -0
- package/dist/core/session/pi/metadata.js +120 -0
- package/dist/core/session/pi/metadata.js.map +1 -0
- package/dist/core/session/pi/text.d.ts +12 -0
- package/dist/core/session/pi/text.d.ts.map +1 -0
- package/dist/core/session/pi/text.js +59 -0
- package/dist/core/session/pi/text.js.map +1 -0
- package/dist/core/session/pi-parser.d.ts +5 -0
- package/dist/core/session/pi-parser.d.ts.map +1 -0
- package/dist/core/session/pi-parser.js +73 -0
- package/dist/core/session/pi-parser.js.map +1 -0
- package/dist/core/session/rows.d.ts +7 -0
- package/dist/core/session/rows.d.ts.map +1 -0
- package/dist/core/session/rows.js +39 -0
- package/dist/core/session/rows.js.map +1 -0
- package/dist/core/session/session-record.d.ts +5 -0
- package/dist/core/session/session-record.d.ts.map +1 -0
- package/dist/core/session/session-record.js +3 -0
- package/dist/core/session/session-record.js.map +1 -0
- package/dist/db/accounting.d.ts +33 -0
- package/dist/db/accounting.d.ts.map +1 -0
- package/dist/db/accounting.js +52 -0
- package/dist/db/accounting.js.map +1 -0
- package/dist/db/chunks.d.ts +62 -0
- package/dist/db/chunks.d.ts.map +1 -0
- package/dist/db/chunks.js +88 -0
- package/dist/db/chunks.js.map +1 -0
- package/dist/db/db.d.ts +752 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +24 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/index.d.ts +2 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +5 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema/attempt-streams.d.ts +304 -0
- package/dist/db/schema/attempt-streams.d.ts.map +1 -0
- package/dist/db/schema/attempt-streams.js +100 -0
- package/dist/db/schema/attempt-streams.js.map +1 -0
- package/dist/db/schema/chunks.d.ts +166 -0
- package/dist/db/schema/chunks.d.ts.map +1 -0
- package/dist/db/schema/chunks.js +47 -0
- package/dist/db/schema/chunks.js.map +1 -0
- package/dist/db/schema/common.d.ts +29 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +11 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/job-accounting.d.ts +143 -0
- package/dist/db/schema/job-accounting.d.ts.map +1 -0
- package/dist/db/schema/job-accounting.js +43 -0
- package/dist/db/schema/job-accounting.js.map +1 -0
- package/dist/db/schema/outbox.d.ts +195 -0
- package/dist/db/schema/outbox.d.ts.map +1 -0
- package/dist/db/schema/outbox.js +5 -0
- package/dist/db/schema/outbox.js.map +1 -0
- package/dist/db/streams.d.ts +141 -0
- package/dist/db/streams.d.ts.map +1 -0
- package/dist/db/streams.js +197 -0
- package/dist/db/streams.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +81 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +2 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +3 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +14 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +16 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/metrics/service.d.ts +2 -0
- package/dist/metrics/service.d.ts.map +1 -0
- package/dist/metrics/service.js +20 -0
- package/dist/metrics/service.js.map +1 -0
- package/dist/presentation/dto/read-logs.d.ts +4 -0
- package/dist/presentation/dto/read-logs.d.ts.map +1 -0
- package/dist/presentation/dto/read-logs.js +22 -0
- package/dist/presentation/dto/read-logs.js.map +1 -0
- package/dist/presentation/routes/append-logs.d.ts +2 -0
- package/dist/presentation/routes/append-logs.d.ts.map +1 -0
- package/dist/presentation/routes/append-logs.js +71 -0
- package/dist/presentation/routes/append-logs.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +3 -0
- package/dist/presentation/routes/index.d.ts.map +1 -0
- package/dist/presentation/routes/index.js +32 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/read-logs.d.ts +2 -0
- package/dist/presentation/routes/read-logs.d.ts.map +1 -0
- package/dist/presentation/routes/read-logs.js +41 -0
- package/dist/presentation/routes/read-logs.js.map +1 -0
- package/dist/presentation/subscribers/on-job-terminated.d.ts +8 -0
- package/dist/presentation/subscribers/on-job-terminated.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-job-terminated.js +32 -0
- package/dist/presentation/subscribers/on-job-terminated.js.map +1 -0
- package/dist/presentation/subscribers/on-log-stream-closed.d.ts +9 -0
- package/dist/presentation/subscribers/on-log-stream-closed.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-log-stream-closed.js +31 -0
- package/dist/presentation/subscribers/on-log-stream-closed.js.map +1 -0
- package/dist/presentation/subscribers/on-step-attempt-terminated.d.ts +3 -0
- package/dist/presentation/subscribers/on-step-attempt-terminated.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-step-attempt-terminated.js +14 -0
- package/dist/presentation/subscribers/on-step-attempt-terminated.js.map +1 -0
- package/dist/temporal/activities/close-abandoned-streams.d.ts +12 -0
- package/dist/temporal/activities/close-abandoned-streams.d.ts.map +1 -0
- package/dist/temporal/activities/close-abandoned-streams.js +33 -0
- package/dist/temporal/activities/close-abandoned-streams.js.map +1 -0
- package/dist/temporal/activities/compact-stream.d.ts +33 -0
- package/dist/temporal/activities/compact-stream.d.ts.map +1 -0
- package/dist/temporal/activities/compact-stream.js +95 -0
- package/dist/temporal/activities/compact-stream.js.map +1 -0
- package/dist/temporal/activities/compaction-reconcile.d.ts +16 -0
- package/dist/temporal/activities/compaction-reconcile.d.ts.map +1 -0
- package/dist/temporal/activities/compaction-reconcile.js +52 -0
- package/dist/temporal/activities/compaction-reconcile.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +14 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +16 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/reap-stale-open-streams.d.ts +4 -0
- package/dist/temporal/activities/reap-stale-open-streams.d.ts.map +1 -0
- package/dist/temporal/activities/reap-stale-open-streams.js +11 -0
- package/dist/temporal/activities/reap-stale-open-streams.js.map +1 -0
- package/dist/temporal/activities/retention-sweep.d.ts +7 -0
- package/dist/temporal/activities/retention-sweep.d.ts.map +1 -0
- package/dist/temporal/activities/retention-sweep.js +19 -0
- package/dist/temporal/activities/retention-sweep.js.map +1 -0
- package/dist/temporal/constants.d.ts +7 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +13 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/workflows/close-abandoned-streams.d.ts +11 -0
- package/dist/temporal/workflows/close-abandoned-streams.d.ts.map +1 -0
- package/dist/temporal/workflows/close-abandoned-streams.js +22 -0
- package/dist/temporal/workflows/close-abandoned-streams.js.map +1 -0
- package/dist/temporal/workflows/compact-stream.d.ts +6 -0
- package/dist/temporal/workflows/compact-stream.d.ts.map +1 -0
- package/dist/temporal/workflows/compact-stream.js +25 -0
- package/dist/temporal/workflows/compact-stream.js.map +1 -0
- package/dist/temporal/workflows/compaction-reconcile-cron.d.ts +3 -0
- package/dist/temporal/workflows/compaction-reconcile-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/compaction-reconcile-cron.js +15 -0
- package/dist/temporal/workflows/compaction-reconcile-cron.js.map +1 -0
- package/dist/temporal/workflows/index.d.ts +6 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +7 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/reap-stale-open-streams-cron.d.ts +2 -0
- package/dist/temporal/workflows/reap-stale-open-streams-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/reap-stale-open-streams-cron.js +15 -0
- package/dist/temporal/workflows/reap-stale-open-streams-cron.js.map +1 -0
- package/dist/temporal/workflows/retention-sweep-cron.d.ts +2 -0
- package/dist/temporal/workflows/retention-sweep-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/retention-sweep-cron.js +23 -0
- package/dist/temporal/workflows/retention-sweep-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +65 -0
- package/drizzle/meta/0000_snapshot.json +529 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +84 -0
- package/src/api/object-storage-credentials.test.ts +70 -0
- package/src/api/object-storage.test.ts +64 -0
- package/src/api/object-storage.ts +226 -0
- package/src/config.test.ts +118 -0
- package/src/config.ts +130 -0
- package/src/core/append-logs.test.ts +563 -0
- package/src/core/append-logs.ts +384 -0
- package/src/core/budget.test.ts +30 -0
- package/src/core/budget.ts +19 -0
- package/src/core/close-stream.ts +72 -0
- package/src/core/compaction.ts +68 -0
- package/src/core/entities/attempt-stream.ts +38 -0
- package/src/core/entities/job-accounting.ts +11 -0
- package/src/core/entities/log-object.test.ts +25 -0
- package/src/core/entities/log-object.ts +18 -0
- package/src/core/errors.ts +37 -0
- package/src/core/finalize-attempt-stream.test.ts +90 -0
- package/src/core/finalize-attempt-stream.ts +59 -0
- package/src/core/read-logs.ts +77 -0
- package/src/core/reap-stale-open-streams.test.ts +137 -0
- package/src/core/reap-stale-open-streams.ts +60 -0
- package/src/core/retention.test.ts +388 -0
- package/src/core/retention.ts +184 -0
- package/src/core/session/claude/rows.ts +223 -0
- package/src/core/session/claude-parser.test.ts +199 -0
- package/src/core/session/claude-parser.ts +38 -0
- package/src/core/session/entry-schema.ts +82 -0
- package/src/core/session/object.ts +57 -0
- package/src/core/session/parse-session.ts +23 -0
- package/src/core/session/pi/message-rows.ts +211 -0
- package/src/core/session/pi/metadata.ts +148 -0
- package/src/core/session/pi/text.ts +83 -0
- package/src/core/session/pi-parser.test.ts +451 -0
- package/src/core/session/pi-parser.ts +107 -0
- package/src/core/session/rows.ts +38 -0
- package/src/core/session/session-record.ts +4 -0
- package/src/db/accounting.ts +81 -0
- package/src/db/chunks.ts +143 -0
- package/src/db/db.ts +22 -0
- package/src/db/index.ts +4 -0
- package/src/db/schema/attempt-streams.ts +106 -0
- package/src/db/schema/chunks.ts +43 -0
- package/src/db/schema/common.ts +12 -0
- package/src/db/schema/job-accounting.ts +38 -0
- package/src/db/schema/outbox.ts +4 -0
- package/src/db/streams.test.ts +47 -0
- package/src/db/streams.ts +368 -0
- package/src/index.ts +77 -0
- package/src/metrics/index.ts +1 -0
- package/src/metrics/instance.ts +33 -0
- package/src/metrics/service.ts +23 -0
- package/src/presentation/dto/read-logs.ts +24 -0
- package/src/presentation/routes/append-logs.test.ts +279 -0
- package/src/presentation/routes/append-logs.ts +66 -0
- package/src/presentation/routes/index.ts +27 -0
- package/src/presentation/routes/read-logs.test.ts +504 -0
- package/src/presentation/routes/read-logs.ts +38 -0
- package/src/presentation/subscribers/on-job-terminated.test.ts +64 -0
- package/src/presentation/subscribers/on-job-terminated.ts +26 -0
- package/src/presentation/subscribers/on-log-stream-closed.test.ts +63 -0
- package/src/presentation/subscribers/on-log-stream-closed.ts +26 -0
- package/src/presentation/subscribers/on-step-attempt-terminated.ts +16 -0
- package/src/temporal/activities/close-abandoned-streams.test.ts +84 -0
- package/src/temporal/activities/close-abandoned-streams.ts +30 -0
- package/src/temporal/activities/compact-stream.test.ts +221 -0
- package/src/temporal/activities/compact-stream.ts +123 -0
- package/src/temporal/activities/compaction-reconcile.test.ts +143 -0
- package/src/temporal/activities/compaction-reconcile.ts +48 -0
- package/src/temporal/activities/index.ts +15 -0
- package/src/temporal/activities/reap-stale-open-streams.test.ts +48 -0
- package/src/temporal/activities/reap-stale-open-streams.ts +14 -0
- package/src/temporal/activities/retention-sweep.test.ts +38 -0
- package/src/temporal/activities/retention-sweep.ts +23 -0
- package/src/temporal/constants.ts +14 -0
- package/src/temporal/workflows/close-abandoned-streams.ts +25 -0
- package/src/temporal/workflows/compact-stream.ts +26 -0
- package/src/temporal/workflows/compaction-reconcile-cron.ts +14 -0
- package/src/temporal/workflows/index.ts +5 -0
- package/src/temporal/workflows/reap-stale-open-streams-cron.ts +13 -0
- package/src/temporal/workflows/retention-sweep-cron.ts +23 -0
- package/test/env.ts +37 -0
- package/test/factories/job-accounting.ts +38 -0
- package/test/fixtures/closed-stream.ts +53 -0
- package/test/fixtures/lease-token.ts +42 -0
- package/test/fixtures/ndjson.ts +34 -0
- package/test/globalSetup.ts +44 -0
- package/test/index.ts +9 -0
- package/test/queries.ts +54 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {gunzipSync} from 'node:zlib';
|
|
3
|
+
import {GetObjectCommand} from '@aws-sdk/client-s3';
|
|
4
|
+
import {
|
|
5
|
+
AUTH_LEASED_JOB,
|
|
6
|
+
AUTH_USER,
|
|
7
|
+
buildUserContext,
|
|
8
|
+
setUserContext,
|
|
9
|
+
} from '@shipfox/api-auth-context';
|
|
10
|
+
import {parseLogRecordLine} from '@shipfox/api-logs-dto';
|
|
11
|
+
import type {
|
|
12
|
+
LogOutcomeDto,
|
|
13
|
+
WorkflowsStepAttemptTerminatedEventDto,
|
|
14
|
+
} from '@shipfox/api-workflows-dto';
|
|
15
|
+
import {
|
|
16
|
+
type AuthMethod,
|
|
17
|
+
ClientError,
|
|
18
|
+
closeApp,
|
|
19
|
+
createApp,
|
|
20
|
+
type FastifyInstance,
|
|
21
|
+
} from '@shipfox/node-fastify';
|
|
22
|
+
import {MockActivityEnvironment} from '@temporalio/testing';
|
|
23
|
+
import {eq, sql} from 'drizzle-orm';
|
|
24
|
+
import type {FastifyRequest} from 'fastify';
|
|
25
|
+
import {deleteObject, s3Client} from '#api/object-storage.js';
|
|
26
|
+
import {config} from '#config.js';
|
|
27
|
+
import type {AttemptStream} from '#core/entities/attempt-stream.js';
|
|
28
|
+
import {buildLogReadResult} from '#core/read-logs.js';
|
|
29
|
+
import {insertChunk} from '#db/chunks.js';
|
|
30
|
+
import {db} from '#db/db.js';
|
|
31
|
+
import {attemptStreams} from '#db/schema/attempt-streams.js';
|
|
32
|
+
import {getOrCreateAttemptStream} from '#db/streams.js';
|
|
33
|
+
import {
|
|
34
|
+
type CompactStreamResult,
|
|
35
|
+
compactStreamActivity,
|
|
36
|
+
} from '#temporal/activities/compact-stream.js';
|
|
37
|
+
import {ndjsonBody, outputLine, recordLine} from '#test/fixtures/ndjson.js';
|
|
38
|
+
import {findStream} from '#test/queries.js';
|
|
39
|
+
import {onStepAttemptTerminated} from '../subscribers/on-step-attempt-terminated.js';
|
|
40
|
+
import {logsRoutes} from './index.js';
|
|
41
|
+
|
|
42
|
+
// AUTH_USER stub: a `Bearer user` request is a member of whatever workspace it names in the
|
|
43
|
+
// `x-test-workspace` header, so each test grants or withholds access against the arranged
|
|
44
|
+
// stream's workspace. The lease method is stubbed only so the append group's auth resolves.
|
|
45
|
+
const fakeUserAuth: AuthMethod = {
|
|
46
|
+
name: AUTH_USER,
|
|
47
|
+
authenticate: (request: FastifyRequest) => {
|
|
48
|
+
if (request.headers.authorization !== 'Bearer user') {
|
|
49
|
+
throw new ClientError('Invalid user token', 'unauthorized', {status: 401});
|
|
50
|
+
}
|
|
51
|
+
const header = request.headers['x-test-workspace'];
|
|
52
|
+
const workspaceId = Array.isArray(header) ? header[0] : header;
|
|
53
|
+
setUserContext(
|
|
54
|
+
request,
|
|
55
|
+
buildUserContext({
|
|
56
|
+
userId: 'user-1',
|
|
57
|
+
email: 'user@example.com',
|
|
58
|
+
memberships: workspaceId ? [{workspaceId, role: 'admin'}] : [],
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
return Promise.resolve();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const stubLeaseAuth: AuthMethod = {name: AUTH_LEASED_JOB, authenticate: () => Promise.resolve()};
|
|
65
|
+
|
|
66
|
+
interface ChunkSpec {
|
|
67
|
+
data: Buffer;
|
|
68
|
+
origin?: 'runner' | 'control';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function arrangeStream(opts: {
|
|
72
|
+
workspaceId: string;
|
|
73
|
+
chunks: (Buffer | ChunkSpec)[];
|
|
74
|
+
state?: 'open' | 'closed';
|
|
75
|
+
truncated?: boolean;
|
|
76
|
+
}): Promise<AttemptStream> {
|
|
77
|
+
const identity = {
|
|
78
|
+
jobId: crypto.randomUUID(),
|
|
79
|
+
stepId: crypto.randomUUID(),
|
|
80
|
+
attempt: 1,
|
|
81
|
+
workspaceId: opts.workspaceId,
|
|
82
|
+
projectId: crypto.randomUUID(),
|
|
83
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
84
|
+
};
|
|
85
|
+
await db().transaction(async (tx) => {
|
|
86
|
+
const stream = await getOrCreateAttemptStream(tx, identity);
|
|
87
|
+
let offset = 0;
|
|
88
|
+
for (const chunk of opts.chunks) {
|
|
89
|
+
const spec = Buffer.isBuffer(chunk) ? {data: chunk, origin: 'runner' as const} : chunk;
|
|
90
|
+
const origin = spec.origin ?? 'runner';
|
|
91
|
+
await insertChunk(tx, {
|
|
92
|
+
streamId: stream.id,
|
|
93
|
+
streamOffset: offset,
|
|
94
|
+
byteLen: spec.data.length,
|
|
95
|
+
data: spec.data,
|
|
96
|
+
origin,
|
|
97
|
+
});
|
|
98
|
+
// Control chunks do not advance the runner byte axis (committed_length).
|
|
99
|
+
if (origin === 'runner') offset += spec.data.length;
|
|
100
|
+
}
|
|
101
|
+
await tx
|
|
102
|
+
.update(attemptStreams)
|
|
103
|
+
.set({
|
|
104
|
+
committedLength: offset,
|
|
105
|
+
state: opts.state ?? 'open',
|
|
106
|
+
...(opts.state === 'closed' ? {closeReason: 'declared', closedAt: sql`now()`} : {}),
|
|
107
|
+
truncated: opts.truncated ?? false,
|
|
108
|
+
})
|
|
109
|
+
.where(eq(attemptStreams.id, stream.id));
|
|
110
|
+
});
|
|
111
|
+
const stream = await findStream(identity);
|
|
112
|
+
if (!stream) throw new Error('arranged stream missing');
|
|
113
|
+
return stream;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function terminatedEvent(opts: {
|
|
117
|
+
workspaceId: string;
|
|
118
|
+
logOutcome: LogOutcomeDto;
|
|
119
|
+
}): WorkflowsStepAttemptTerminatedEventDto {
|
|
120
|
+
return {
|
|
121
|
+
jobId: crypto.randomUUID(),
|
|
122
|
+
stepId: crypto.randomUUID(),
|
|
123
|
+
attempt: 1,
|
|
124
|
+
workspaceId: opts.workspaceId,
|
|
125
|
+
projectId: crypto.randomUUID(),
|
|
126
|
+
workflowRunId: crypto.randomUUID(),
|
|
127
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
128
|
+
logOutcome: opts.logOutcome,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function consumeTerminatedEvent(
|
|
133
|
+
payload: WorkflowsStepAttemptTerminatedEventDto,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
await onStepAttemptTerminated(payload);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function getObjectBytes(key: string): Promise<Buffer> {
|
|
139
|
+
const res = await s3Client().send(
|
|
140
|
+
new GetObjectCommand({Bucket: config.LOG_STORAGE_S3_BUCKET, Key: key}),
|
|
141
|
+
);
|
|
142
|
+
if (!res.Body) throw new Error('object has no body');
|
|
143
|
+
return Buffer.from(await res.Body.transformToByteArray());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function runCompaction(streamId: string): Promise<CompactStreamResult> {
|
|
147
|
+
return new MockActivityEnvironment().run(compactStreamActivity, {streamId});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function compact(streamId: string): Promise<string> {
|
|
151
|
+
const result = await runCompaction(streamId);
|
|
152
|
+
if (result.outcome !== 'compacted') throw new Error(`expected compacted, got ${result.outcome}`);
|
|
153
|
+
return result.objectKey;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
describe('GET /steps/:stepId/attempts/:attempt/logs', () => {
|
|
157
|
+
let app: FastifyInstance;
|
|
158
|
+
|
|
159
|
+
beforeAll(async () => {
|
|
160
|
+
app = await createApp({
|
|
161
|
+
auth: [fakeUserAuth, stubLeaseAuth],
|
|
162
|
+
routes: logsRoutes,
|
|
163
|
+
swagger: false,
|
|
164
|
+
});
|
|
165
|
+
await app.ready();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
afterAll(async () => {
|
|
169
|
+
await closeApp();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
function readUrl(stepId: string, attempt: number, cursor: number): string {
|
|
173
|
+
return `/steps/${stepId}/attempts/${attempt}/logs?cursor=${cursor}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function authedGet(stream: AttemptStream, cursor: number, workspaceId = stream.workspaceId) {
|
|
177
|
+
return app.inject({
|
|
178
|
+
method: 'GET',
|
|
179
|
+
url: readUrl(stream.stepId, stream.attempt, cursor),
|
|
180
|
+
headers: {authorization: 'Bearer user', 'x-test-workspace': workspaceId},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
it('rejects a request without a session token', async () => {
|
|
185
|
+
const res = await app.inject({method: 'GET', url: readUrl(crypto.randomUUID(), 1, 0)});
|
|
186
|
+
|
|
187
|
+
expect(res.statusCode).toBe(401);
|
|
188
|
+
expect(res.json().code).toBe('unauthorized');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('returns 404 for a step that has no stream', async () => {
|
|
192
|
+
const res = await app.inject({
|
|
193
|
+
method: 'GET',
|
|
194
|
+
url: readUrl(crypto.randomUUID(), 1, 0),
|
|
195
|
+
headers: {authorization: 'Bearer user', 'x-test-workspace': crypto.randomUUID()},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(res.statusCode).toBe(404);
|
|
199
|
+
expect(res.json().code).toBe('not-found');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('returns 404 before the terminated event, then returns a closed empty drained stream', async () => {
|
|
203
|
+
const terminal = terminatedEvent({
|
|
204
|
+
workspaceId: crypto.randomUUID(),
|
|
205
|
+
logOutcome: 'drained',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const beforeEvent = await app.inject({
|
|
209
|
+
method: 'GET',
|
|
210
|
+
url: readUrl(terminal.stepId, terminal.attempt, 0),
|
|
211
|
+
headers: {authorization: 'Bearer user', 'x-test-workspace': terminal.workspaceId},
|
|
212
|
+
});
|
|
213
|
+
await consumeTerminatedEvent(terminal);
|
|
214
|
+
const afterEvent = await app.inject({
|
|
215
|
+
method: 'GET',
|
|
216
|
+
url: readUrl(terminal.stepId, terminal.attempt, 0),
|
|
217
|
+
headers: {authorization: 'Bearer user', 'x-test-workspace': terminal.workspaceId},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(beforeEvent.statusCode).toBe(404);
|
|
221
|
+
expect(beforeEvent.json().code).toBe('not-found');
|
|
222
|
+
expect(afterEvent.statusCode).toBe(200);
|
|
223
|
+
const body = afterEvent.json();
|
|
224
|
+
expect(body.mode).toBe('inline');
|
|
225
|
+
expect(body.state).toBe('closed');
|
|
226
|
+
expect(body.truncated).toBe(false);
|
|
227
|
+
expect(body.ndjson).toBe('');
|
|
228
|
+
const stream = await findStream(terminal);
|
|
229
|
+
expect(stream?.state).toBe('closed');
|
|
230
|
+
expect(stream?.closeReason).toBe('declared');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('returns 404 before the terminated event, then returns a runner_lost abandoned stream', async () => {
|
|
234
|
+
const terminal = terminatedEvent({
|
|
235
|
+
workspaceId: crypto.randomUUID(),
|
|
236
|
+
logOutcome: 'abandoned',
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const beforeEvent = await app.inject({
|
|
240
|
+
method: 'GET',
|
|
241
|
+
url: readUrl(terminal.stepId, terminal.attempt, 0),
|
|
242
|
+
headers: {authorization: 'Bearer user', 'x-test-workspace': terminal.workspaceId},
|
|
243
|
+
});
|
|
244
|
+
await consumeTerminatedEvent(terminal);
|
|
245
|
+
const afterEvent = await app.inject({
|
|
246
|
+
method: 'GET',
|
|
247
|
+
url: readUrl(terminal.stepId, terminal.attempt, 0),
|
|
248
|
+
headers: {authorization: 'Bearer user', 'x-test-workspace': terminal.workspaceId},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(beforeEvent.statusCode).toBe(404);
|
|
252
|
+
expect(beforeEvent.json().code).toBe('not-found');
|
|
253
|
+
expect(afterEvent.statusCode).toBe(200);
|
|
254
|
+
const body = afterEvent.json();
|
|
255
|
+
expect(body.mode).toBe('inline');
|
|
256
|
+
expect(body.state).toBe('closed');
|
|
257
|
+
expect(body.truncated).toBe(true);
|
|
258
|
+
const records = body.ndjson.split('\n').filter(Boolean).map(parseLogRecordLine);
|
|
259
|
+
expect(records).toMatchObject([{type: 'runner_lost'}]);
|
|
260
|
+
const stream = await findStream(terminal);
|
|
261
|
+
expect(stream?.state).toBe('closed');
|
|
262
|
+
expect(stream?.closeReason).toBe('timeout');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('keeps returning 404 for a running missing attempt', async () => {
|
|
266
|
+
const running = {stepId: crypto.randomUUID(), attempt: 1, workspaceId: crypto.randomUUID()};
|
|
267
|
+
|
|
268
|
+
const res = await app.inject({
|
|
269
|
+
method: 'GET',
|
|
270
|
+
url: readUrl(running.stepId, running.attempt, 0),
|
|
271
|
+
headers: {authorization: 'Bearer user', 'x-test-workspace': running.workspaceId},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(res.statusCode).toBe(404);
|
|
275
|
+
expect(res.json().code).toBe('not-found');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('returns 404 (no existence leak) when the caller is not a member of the workspace', async () => {
|
|
279
|
+
const stream = await arrangeStream({
|
|
280
|
+
workspaceId: crypto.randomUUID(),
|
|
281
|
+
chunks: [ndjsonBody(outputLine('secret\n'))],
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const res = await authedGet(stream, 0, crypto.randomUUID());
|
|
285
|
+
|
|
286
|
+
expect(res.statusCode).toBe(404);
|
|
287
|
+
expect(res.json().code).toBe('not-found');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('serves an open stream inline with the next cursor and stream state', async () => {
|
|
291
|
+
const workspaceId = crypto.randomUUID();
|
|
292
|
+
const lineA = outputLine('alpha\n');
|
|
293
|
+
const lineB = outputLine('beta\n');
|
|
294
|
+
const stream = await arrangeStream({
|
|
295
|
+
workspaceId,
|
|
296
|
+
chunks: [ndjsonBody(lineA), ndjsonBody(lineB)],
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const res = await authedGet(stream, 0);
|
|
300
|
+
|
|
301
|
+
expect(res.statusCode).toBe(200);
|
|
302
|
+
const body = res.json();
|
|
303
|
+
expect(body.mode).toBe('inline');
|
|
304
|
+
expect(body.state).toBe('open');
|
|
305
|
+
expect(body.truncated).toBe(false);
|
|
306
|
+
expect(body.has_more).toBe(false);
|
|
307
|
+
expect(body.ndjson).toBe(lineA + lineB);
|
|
308
|
+
const records = body.ndjson.split('\n').filter(Boolean).map(parseLogRecordLine);
|
|
309
|
+
expect(records).toEqual([
|
|
310
|
+
{v: 1, ts: 1, type: 'output', stream: 'stdout', data: 'alpha\n'},
|
|
311
|
+
{v: 1, ts: 1, type: 'output', stream: 'stdout', data: 'beta\n'},
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
const reread = await authedGet(stream, body.next_cursor);
|
|
315
|
+
expect(reread.json().ndjson).toBe('');
|
|
316
|
+
expect(reread.json().has_more).toBe(false);
|
|
317
|
+
expect(reread.json().next_cursor).toBe(body.next_cursor);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('interleaves server control records with runner bytes in seq order', async () => {
|
|
321
|
+
const workspaceId = crypto.randomUUID();
|
|
322
|
+
const stream = await arrangeStream({
|
|
323
|
+
workspaceId,
|
|
324
|
+
state: 'closed',
|
|
325
|
+
chunks: [
|
|
326
|
+
{data: ndjsonBody(outputLine('a\n'))},
|
|
327
|
+
{data: ndjsonBody(recordLine({type: 'capped'})), origin: 'control'},
|
|
328
|
+
{data: ndjsonBody(outputLine('b\n'))},
|
|
329
|
+
],
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const res = await authedGet(stream, 0);
|
|
333
|
+
|
|
334
|
+
const records = res.json().ndjson.split('\n').filter(Boolean).map(parseLogRecordLine);
|
|
335
|
+
expect(records.map((record: {type: string}) => record.type)).toEqual([
|
|
336
|
+
'output',
|
|
337
|
+
'capped',
|
|
338
|
+
'output',
|
|
339
|
+
]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('returns inline bytes byte-identical to the compacted object', async () => {
|
|
343
|
+
const workspaceId = crypto.randomUUID();
|
|
344
|
+
const stream = await arrangeStream({
|
|
345
|
+
workspaceId,
|
|
346
|
+
state: 'closed',
|
|
347
|
+
chunks: [ndjsonBody(outputLine('one\n')), ndjsonBody(outputLine('two\n'))],
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const inline = await authedGet(stream, 0);
|
|
351
|
+
expect(inline.json().mode).toBe('inline');
|
|
352
|
+
const inlineNdjson = inline.json().ndjson;
|
|
353
|
+
|
|
354
|
+
const objectKey = await compact(stream.id);
|
|
355
|
+
const objectBytes = gunzipSync(await getObjectBytes(objectKey)).toString('utf8');
|
|
356
|
+
expect(objectBytes).toBe(inlineNdjson);
|
|
357
|
+
|
|
358
|
+
await deleteObject(objectKey);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('pages a stream larger than the inline cap and drains via has_more', async () => {
|
|
362
|
+
const workspaceId = crypto.randomUUID();
|
|
363
|
+
// Test cap is 256 bytes (LOG_READ_INLINE_MAX_BYTES); these six lines exceed it.
|
|
364
|
+
const lines = Array.from({length: 6}, (_, i) => outputLine(`line-${i}\n`));
|
|
365
|
+
const stream = await arrangeStream({
|
|
366
|
+
workspaceId,
|
|
367
|
+
chunks: lines.map((line) => ndjsonBody(line)),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
let cursor = 0;
|
|
371
|
+
let collected = '';
|
|
372
|
+
let pages = 0;
|
|
373
|
+
let hasMore = true;
|
|
374
|
+
while (hasMore && pages < 20) {
|
|
375
|
+
const body = (await authedGet(stream, cursor)).json();
|
|
376
|
+
collected += body.ndjson;
|
|
377
|
+
cursor = body.next_cursor;
|
|
378
|
+
hasMore = body.has_more;
|
|
379
|
+
pages += 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
expect(pages).toBeGreaterThan(1);
|
|
383
|
+
expect(hasMore).toBe(false);
|
|
384
|
+
expect(collected).toBe(lines.join(''));
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('serves a closed-but-uncompacted stream inline and surfaces truncation', async () => {
|
|
388
|
+
const workspaceId = crypto.randomUUID();
|
|
389
|
+
const stream = await arrangeStream({
|
|
390
|
+
workspaceId,
|
|
391
|
+
state: 'closed',
|
|
392
|
+
truncated: true,
|
|
393
|
+
chunks: [ndjsonBody(outputLine('x\n'))],
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const body = (await authedGet(stream, 0)).json();
|
|
397
|
+
|
|
398
|
+
expect(body.mode).toBe('inline');
|
|
399
|
+
expect(body.state).toBe('closed');
|
|
400
|
+
expect(body.truncated).toBe(true);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('serves a compacted stream as a working presigned URL', async () => {
|
|
404
|
+
const workspaceId = crypto.randomUUID();
|
|
405
|
+
const ndjson = outputLine('hello\n');
|
|
406
|
+
const stream = await arrangeStream({
|
|
407
|
+
workspaceId,
|
|
408
|
+
state: 'closed',
|
|
409
|
+
chunks: [ndjsonBody(ndjson)],
|
|
410
|
+
});
|
|
411
|
+
const objectKey = await compact(stream.id);
|
|
412
|
+
|
|
413
|
+
const body = (await authedGet(stream, 0)).json();
|
|
414
|
+
|
|
415
|
+
expect(body.mode).toBe('presigned');
|
|
416
|
+
expect(body.state).toBe('closed');
|
|
417
|
+
expect(typeof body.url).toBe('string');
|
|
418
|
+
expect(body.total_bytes).toBe(stream.committedLength);
|
|
419
|
+
const ttlMs = config.LOG_READ_URL_TTL_SECONDS * 1000;
|
|
420
|
+
expect(Math.abs(Date.parse(body.expires_at) - (Date.now() + ttlMs))).toBeLessThan(60_000);
|
|
421
|
+
|
|
422
|
+
const fetched = await fetch(body.url);
|
|
423
|
+
expect(fetched.status).toBe(200);
|
|
424
|
+
const raw = Buffer.from(await fetched.arrayBuffer());
|
|
425
|
+
// undici may auto-decompress the gzip object (Content-Encoding: gzip), so accept either.
|
|
426
|
+
const content =
|
|
427
|
+
raw[0] === 0x1f && raw[1] === 0x8b ? gunzipSync(raw).toString('utf8') : raw.toString('utf8');
|
|
428
|
+
expect(content).toBe(ndjson);
|
|
429
|
+
|
|
430
|
+
await deleteObject(objectKey);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('serves stream metadata on the logs endpoint for an existing stream', async () => {
|
|
434
|
+
const stream = await arrangeStream({
|
|
435
|
+
workspaceId: crypto.randomUUID(),
|
|
436
|
+
chunks: [],
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const res = await authedGet(stream, 0);
|
|
440
|
+
|
|
441
|
+
expect(res.statusCode).toBe(200);
|
|
442
|
+
expect(res.json()).toMatchObject({
|
|
443
|
+
mode: 'inline',
|
|
444
|
+
state: 'open',
|
|
445
|
+
truncated: false,
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('serves normalized session records as log NDJSON with terminal stream state', async () => {
|
|
450
|
+
const row = {
|
|
451
|
+
kind: 'message',
|
|
452
|
+
timestamp: 1,
|
|
453
|
+
role: 'assistant',
|
|
454
|
+
label: 'assistant',
|
|
455
|
+
meta: [],
|
|
456
|
+
text: 'Done.',
|
|
457
|
+
terminalFailure: false,
|
|
458
|
+
} as const;
|
|
459
|
+
const stream = await arrangeStream({
|
|
460
|
+
workspaceId: crypto.randomUUID(),
|
|
461
|
+
state: 'closed',
|
|
462
|
+
truncated: true,
|
|
463
|
+
chunks: [ndjsonBody(recordLine({type: 'agent_session', row}))],
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const res = await authedGet(stream, 0);
|
|
467
|
+
const body = res.json();
|
|
468
|
+
|
|
469
|
+
expect(res.statusCode).toBe(200);
|
|
470
|
+
expect(body).toMatchObject({
|
|
471
|
+
mode: 'inline',
|
|
472
|
+
state: 'closed',
|
|
473
|
+
truncated: true,
|
|
474
|
+
});
|
|
475
|
+
expect(parseLogRecordLine(body.ndjson.trim())).toEqual({
|
|
476
|
+
v: 1,
|
|
477
|
+
ts: 1,
|
|
478
|
+
type: 'agent_session',
|
|
479
|
+
row,
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
describe('buildLogReadResult compaction boundary', () => {
|
|
485
|
+
// The route always reloads the row, so the race the guard defends against (a closed stream
|
|
486
|
+
// that compacts between the load and the chunk read) can't be staged through HTTP. Exercise
|
|
487
|
+
// the guard directly: hand it a stale pre-compaction snapshot while the row has since compacted.
|
|
488
|
+
it('re-reads and serves presigned when a closed stream compacts after it was loaded', async () => {
|
|
489
|
+
const stale = await arrangeStream({
|
|
490
|
+
workspaceId: crypto.randomUUID(),
|
|
491
|
+
state: 'closed',
|
|
492
|
+
chunks: [],
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
await db()
|
|
496
|
+
.update(attemptStreams)
|
|
497
|
+
.set({objectKey: 'logs/compacted-after-load.ndjson.gz'})
|
|
498
|
+
.where(eq(attemptStreams.id, stale.id));
|
|
499
|
+
|
|
500
|
+
const result = await buildLogReadResult(stale, 0);
|
|
501
|
+
|
|
502
|
+
expect(result.mode).toBe('presigned');
|
|
503
|
+
});
|
|
504
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {requireUserContext} from '@shipfox/api-auth-context';
|
|
2
|
+
import {readLogsQuerySchema, readLogsResponseSchema} from '@shipfox/api-logs-dto';
|
|
3
|
+
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
4
|
+
import {z} from 'zod';
|
|
5
|
+
import {buildLogReadResult} from '#core/read-logs.js';
|
|
6
|
+
import {getStreamByStepAttempt} from '#db/streams.js';
|
|
7
|
+
import {toReadLogsDto} from '#presentation/dto/read-logs.js';
|
|
8
|
+
|
|
9
|
+
export const readLogsRoute = defineRoute({
|
|
10
|
+
method: 'GET',
|
|
11
|
+
path: '/:stepId/attempts/:attempt/logs',
|
|
12
|
+
description:
|
|
13
|
+
'Read a page of logs for a step attempt: inline NDJSON while the stream is hot, a presigned object URL once it is compacted.',
|
|
14
|
+
schema: {
|
|
15
|
+
params: z.object({
|
|
16
|
+
stepId: z.string().uuid(),
|
|
17
|
+
attempt: z.coerce.number().int().min(1),
|
|
18
|
+
}),
|
|
19
|
+
querystring: readLogsQuerySchema,
|
|
20
|
+
response: {
|
|
21
|
+
200: readLogsResponseSchema,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
handler: async (request) => {
|
|
25
|
+
const user = requireUserContext(request);
|
|
26
|
+
const {stepId, attempt} = request.params;
|
|
27
|
+
const {cursor} = request.query;
|
|
28
|
+
|
|
29
|
+
const stream = await getStreamByStepAttempt({stepId, attempt});
|
|
30
|
+
// 404 covers both "no such stream" and "not your workspace" so the endpoint never
|
|
31
|
+
// leaks the existence of another workspace's step.
|
|
32
|
+
if (!stream || !user.canAccess(stream.workspaceId)) {
|
|
33
|
+
throw new ClientError('Logs not found', 'not-found', {status: 404});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return toReadLogsDto(await buildLogReadResult(stream, cursor));
|
|
37
|
+
},
|
|
38
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {WorkflowsJobTerminatedEventDto} from '@shipfox/api-workflows-dto';
|
|
2
|
+
import {config} from '#config.js';
|
|
3
|
+
import {LOGS_LIFECYCLE_TASK_QUEUE} from '#temporal/constants.js';
|
|
4
|
+
import {onJobTerminated} from './on-job-terminated.js';
|
|
5
|
+
|
|
6
|
+
const startMock = vi.fn();
|
|
7
|
+
|
|
8
|
+
vi.mock('@shipfox/node-temporal', () => ({
|
|
9
|
+
temporalClient: () => ({
|
|
10
|
+
workflow: {
|
|
11
|
+
start: startMock,
|
|
12
|
+
},
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
function buildPayload(jobId: string): WorkflowsJobTerminatedEventDto {
|
|
17
|
+
return {
|
|
18
|
+
jobId,
|
|
19
|
+
workflowRunId: crypto.randomUUID(),
|
|
20
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
21
|
+
status: 'failed',
|
|
22
|
+
statusReason: null,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function alreadyStartedError(): Error {
|
|
27
|
+
const error = new Error('Workflow execution already started');
|
|
28
|
+
error.name = 'WorkflowExecutionAlreadyStartedError';
|
|
29
|
+
return error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('onJobTerminated', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
startMock.mockReset();
|
|
35
|
+
startMock.mockResolvedValue({});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('arms the close-abandoned-streams workflow keyed on the job id', async () => {
|
|
39
|
+
const jobId = crypto.randomUUID();
|
|
40
|
+
|
|
41
|
+
await onJobTerminated(buildPayload(jobId));
|
|
42
|
+
|
|
43
|
+
expect(startMock).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(startMock).toHaveBeenCalledWith('closeAbandonedStreams', {
|
|
45
|
+
taskQueue: LOGS_LIFECYCLE_TASK_QUEUE,
|
|
46
|
+
workflowId: `logs-close:${jobId}`,
|
|
47
|
+
args: [{jobId, graceSeconds: config.LOG_STREAM_CLOSE_GRACE_SECONDS}],
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('swallows a redelivered event when the workflow is already started', async () => {
|
|
52
|
+
startMock.mockRejectedValue(alreadyStartedError());
|
|
53
|
+
|
|
54
|
+
await expect(onJobTerminated(buildPayload(crypto.randomUUID()))).resolves.toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('re-throws an unexpected start failure so the outbox retries delivery', async () => {
|
|
58
|
+
startMock.mockRejectedValue(new Error('temporal unreachable'));
|
|
59
|
+
|
|
60
|
+
await expect(onJobTerminated(buildPayload(crypto.randomUUID()))).rejects.toThrow(
|
|
61
|
+
'temporal unreachable',
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type {WorkflowsJobTerminatedEventDto} from '@shipfox/api-workflows-dto';
|
|
2
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
3
|
+
import {temporalClient} from '@shipfox/node-temporal';
|
|
4
|
+
import {config} from '#config.js';
|
|
5
|
+
import {LOGS_LIFECYCLE_TASK_QUEUE} from '#temporal/constants.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A job reached a terminal state (any path: completion, cancellation, lease-expiry,
|
|
9
|
+
* timeout). Arm the grace-then-close workflow for any of its streams the runner never
|
|
10
|
+
* ended itself. Deduped by workflow id, so a redelivered event is a no-op.
|
|
11
|
+
*/
|
|
12
|
+
export async function onJobTerminated(payload: WorkflowsJobTerminatedEventDto): Promise<void> {
|
|
13
|
+
try {
|
|
14
|
+
await temporalClient().workflow.start('closeAbandonedStreams', {
|
|
15
|
+
taskQueue: LOGS_LIFECYCLE_TASK_QUEUE,
|
|
16
|
+
workflowId: `logs-close:${payload.jobId}`,
|
|
17
|
+
args: [{jobId: payload.jobId, graceSeconds: config.LOG_STREAM_CLOSE_GRACE_SECONDS}],
|
|
18
|
+
});
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error instanceof Error && error.name === 'WorkflowExecutionAlreadyStartedError') {
|
|
21
|
+
logger().debug({jobId: payload.jobId}, 'Close-abandoned-streams workflow already started');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type {LogStreamClosedEvent} from '@shipfox/api-logs-dto';
|
|
2
|
+
import {LOGS_COMPACTION_TASK_QUEUE} from '#temporal/constants.js';
|
|
3
|
+
import {onLogStreamClosed} from './on-log-stream-closed.js';
|
|
4
|
+
|
|
5
|
+
const startMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
|
|
7
|
+
vi.mock('@shipfox/node-temporal', () => ({
|
|
8
|
+
temporalClient: () => ({
|
|
9
|
+
workflow: {
|
|
10
|
+
start: startMock,
|
|
11
|
+
},
|
|
12
|
+
}),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
function buildPayload(streamId: string): LogStreamClosedEvent {
|
|
16
|
+
return {
|
|
17
|
+
workspaceId: crypto.randomUUID(),
|
|
18
|
+
jobId: crypto.randomUUID(),
|
|
19
|
+
stepId: crypto.randomUUID(),
|
|
20
|
+
attempt: 1,
|
|
21
|
+
streamId,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function alreadyStartedError(): Error {
|
|
26
|
+
const error = new Error('Workflow execution already started');
|
|
27
|
+
error.name = 'WorkflowExecutionAlreadyStartedError';
|
|
28
|
+
return error;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('onLogStreamClosed', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
startMock.mockReset();
|
|
34
|
+
startMock.mockResolvedValue({});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('starts the compaction workflow keyed on the stream id', async () => {
|
|
38
|
+
const streamId = crypto.randomUUID();
|
|
39
|
+
|
|
40
|
+
await onLogStreamClosed(buildPayload(streamId));
|
|
41
|
+
|
|
42
|
+
expect(startMock).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(startMock).toHaveBeenCalledWith('compactStream', {
|
|
44
|
+
taskQueue: LOGS_COMPACTION_TASK_QUEUE,
|
|
45
|
+
workflowId: `logs-compact:${streamId}`,
|
|
46
|
+
args: [{streamId}],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('swallows a redelivered event when the workflow is already started', async () => {
|
|
51
|
+
startMock.mockRejectedValue(alreadyStartedError());
|
|
52
|
+
|
|
53
|
+
await expect(onLogStreamClosed(buildPayload(crypto.randomUUID()))).resolves.toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('re-throws an unexpected start failure so the outbox retries delivery', async () => {
|
|
57
|
+
startMock.mockRejectedValue(new Error('temporal unreachable'));
|
|
58
|
+
|
|
59
|
+
await expect(onLogStreamClosed(buildPayload(crypto.randomUUID()))).rejects.toThrow(
|
|
60
|
+
'temporal unreachable',
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|