@shipfox/annotations 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 +67 -0
- package/LICENSE +21 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +26 -0
- package/dist/config.js.map +1 -0
- package/dist/core/entities/annotation.d.ts +21 -0
- package/dist/core/entities/annotation.d.ts.map +1 -0
- package/dist/core/entities/annotation.js +3 -0
- package/dist/core/entities/annotation.js.map +1 -0
- package/dist/core/entities/index.d.ts +2 -0
- package/dist/core/entities/index.d.ts.map +1 -0
- package/dist/core/entities/index.js +3 -0
- package/dist/core/entities/index.js.map +1 -0
- package/dist/core/errors.d.ts +13 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +20 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/write-annotations.d.ts +25 -0
- package/dist/core/write-annotations.d.ts.map +1 -0
- package/dist/core/write-annotations.js +105 -0
- package/dist/core/write-annotations.js.map +1 -0
- package/dist/db/annotations.d.ts +63 -0
- package/dist/db/annotations.d.ts.map +1 -0
- package/dist/db/annotations.js +96 -0
- package/dist/db/annotations.js.map +1 -0
- package/dist/db/db.d.ts +304 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +18 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/index.d.ts +4 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +6 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema/annotations.d.ts +302 -0
- package/dist/db/schema/annotations.d.ts.map +1 -0
- package/dist/db/schema/annotations.js +67 -0
- package/dist/db/schema/annotations.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation/dto/annotation.d.ts +4 -0
- package/dist/presentation/dto/annotation.d.ts.map +1 -0
- package/dist/presentation/dto/annotation.js +15 -0
- package/dist/presentation/dto/annotation.js.map +1 -0
- package/dist/presentation/dto/index.d.ts +2 -0
- package/dist/presentation/dto/index.d.ts.map +1 -0
- package/dist/presentation/dto/index.js +3 -0
- package/dist/presentation/dto/index.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 +21 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/read-annotations.d.ts +2 -0
- package/dist/presentation/routes/read-annotations.d.ts.map +1 -0
- package/dist/presentation/routes/read-annotations.js +49 -0
- package/dist/presentation/routes/read-annotations.js.map +1 -0
- package/dist/presentation/routes/write-annotations.d.ts +2 -0
- package/dist/presentation/routes/write-annotations.d.ts.map +1 -0
- package/dist/presentation/routes/write-annotations.js +73 -0
- package/dist/presentation/routes/write-annotations.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +32 -0
- package/drizzle/meta/0000_snapshot.json +247 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +69 -0
- package/src/config.test.ts +45 -0
- package/src/config.ts +26 -0
- package/src/core/entities/annotation.ts +21 -0
- package/src/core/entities/index.ts +1 -0
- package/src/core/errors.ts +20 -0
- package/src/core/write-annotations.test.ts +261 -0
- package/src/core/write-annotations.ts +142 -0
- package/src/db/annotations.test.ts +261 -0
- package/src/db/annotations.ts +180 -0
- package/src/db/db.ts +19 -0
- package/src/db/index.ts +7 -0
- package/src/db/schema/annotations.test.ts +50 -0
- package/src/db/schema/annotations.ts +96 -0
- package/src/db/schema/common.ts +3 -0
- package/src/index.ts +17 -0
- package/src/presentation/dto/annotation.test.ts +42 -0
- package/src/presentation/dto/annotation.ts +16 -0
- package/src/presentation/dto/index.ts +1 -0
- package/src/presentation/routes/index.ts +17 -0
- package/src/presentation/routes/read-annotations.test.ts +279 -0
- package/src/presentation/routes/read-annotations.ts +50 -0
- package/src/presentation/routes/write-annotations.test.ts +188 -0
- package/src/presentation/routes/write-annotations.ts +76 -0
- package/test/env.ts +5 -0
- package/test/factories/annotation.ts +57 -0
- package/test/fixtures/lease-token.ts +43 -0
- package/test/globalSetup.ts +20 -0
- package/test/index.ts +2 -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,261 @@
|
|
|
1
|
+
import {eq} from 'drizzle-orm';
|
|
2
|
+
import {withAnnotationLock} from '#db/annotations.js';
|
|
3
|
+
import {db} from '#db/db.js';
|
|
4
|
+
import {annotations} from '#db/schema/annotations.js';
|
|
5
|
+
import {annotationFactory} from '#test/index.js';
|
|
6
|
+
import {
|
|
7
|
+
AnnotationBodyTooLargeError,
|
|
8
|
+
AnnotationCountLimitExceededError,
|
|
9
|
+
AnnotationTotalBytesLimitExceededError,
|
|
10
|
+
} from './errors.js';
|
|
11
|
+
import {type WriteAnnotationsParams, writeAnnotations} from './write-annotations.js';
|
|
12
|
+
|
|
13
|
+
describe('writeAnnotations', () => {
|
|
14
|
+
let params: WriteAnnotationsParams;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
params = {
|
|
18
|
+
workspaceId: crypto.randomUUID(),
|
|
19
|
+
projectId: crypto.randomUUID(),
|
|
20
|
+
workflowRunId: crypto.randomUUID(),
|
|
21
|
+
workflowRunAttempt: 1,
|
|
22
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
23
|
+
jobId: crypto.randomUUID(),
|
|
24
|
+
jobExecutionId: crypto.randomUUID(),
|
|
25
|
+
originStepId: crypto.randomUUID(),
|
|
26
|
+
originStepAttempt: 1,
|
|
27
|
+
operations: [],
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('replaces by context and preserves sequence across updates', async () => {
|
|
32
|
+
const created = await writeAnnotations({
|
|
33
|
+
...params,
|
|
34
|
+
operations: [{context: 'deploy', style: 'success', op: 'replace', body: 'first'}],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const updated = await writeAnnotations({
|
|
38
|
+
...params,
|
|
39
|
+
originStepId: crypto.randomUUID(),
|
|
40
|
+
originStepAttempt: 2,
|
|
41
|
+
operations: [{context: 'deploy', style: 'warning', op: 'replace', body: 'second'}],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const rows = await db()
|
|
45
|
+
.select()
|
|
46
|
+
.from(annotations)
|
|
47
|
+
.where(eq(annotations.jobExecutionId, params.jobExecutionId));
|
|
48
|
+
expect(rows).toHaveLength(1);
|
|
49
|
+
expect(rows[0]).toMatchObject({
|
|
50
|
+
id: created.annotations[0]?.id,
|
|
51
|
+
context: 'deploy',
|
|
52
|
+
style: 'warning',
|
|
53
|
+
body: 'second',
|
|
54
|
+
sequence: 1,
|
|
55
|
+
originStepAttempt: 2,
|
|
56
|
+
});
|
|
57
|
+
expect(updated.annotations[0]?.id).toBe(created.annotations[0]?.id);
|
|
58
|
+
expect(updated.accounting).toEqual({annotationCount: 1, totalBodyBytes: 6});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('skips unchanged replace writes and preserves original attribution', async () => {
|
|
62
|
+
const created = await writeAnnotations({
|
|
63
|
+
...params,
|
|
64
|
+
operations: [{context: 'deploy', style: 'warning', op: 'replace', body: 'same'}],
|
|
65
|
+
});
|
|
66
|
+
const originalOriginStepId = params.originStepId;
|
|
67
|
+
|
|
68
|
+
const unchanged = await writeAnnotations({
|
|
69
|
+
...params,
|
|
70
|
+
originStepId: crypto.randomUUID(),
|
|
71
|
+
originStepAttempt: 2,
|
|
72
|
+
operations: [{context: 'deploy', style: 'warning', op: 'replace', body: 'same'}],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const rows = await db()
|
|
76
|
+
.select()
|
|
77
|
+
.from(annotations)
|
|
78
|
+
.where(eq(annotations.jobExecutionId, params.jobExecutionId));
|
|
79
|
+
expect(rows).toHaveLength(1);
|
|
80
|
+
expect(rows[0]).toMatchObject({
|
|
81
|
+
id: created.annotations[0]?.id,
|
|
82
|
+
context: 'deploy',
|
|
83
|
+
style: 'warning',
|
|
84
|
+
body: 'same',
|
|
85
|
+
sequence: 1,
|
|
86
|
+
originStepId: originalOriginStepId,
|
|
87
|
+
originStepAttempt: 1,
|
|
88
|
+
});
|
|
89
|
+
expect(unchanged.annotations[0]?.id).toBe(created.annotations[0]?.id);
|
|
90
|
+
expect(unchanged.accounting).toEqual({annotationCount: 1, totalBodyBytes: 4});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('appends to an existing context', async () => {
|
|
94
|
+
await annotationFactory.create({
|
|
95
|
+
...params,
|
|
96
|
+
id: crypto.randomUUID(),
|
|
97
|
+
context: 'summary',
|
|
98
|
+
style: 'info',
|
|
99
|
+
body: 'hello',
|
|
100
|
+
bodyBytes: Buffer.byteLength('hello'),
|
|
101
|
+
sequence: 1,
|
|
102
|
+
createdAt: new Date(),
|
|
103
|
+
updatedAt: new Date(),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const result = await writeAnnotations({
|
|
107
|
+
...params,
|
|
108
|
+
operations: [{context: 'summary', style: 'info', op: 'append', body: ' world'}],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const rows = await db()
|
|
112
|
+
.select()
|
|
113
|
+
.from(annotations)
|
|
114
|
+
.where(eq(annotations.jobExecutionId, params.jobExecutionId));
|
|
115
|
+
expect(rows[0]).toMatchObject({context: 'summary', body: 'hello world', sequence: 1});
|
|
116
|
+
expect(result.accounting).toEqual({annotationCount: 1, totalBodyBytes: 11});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('removes a context and returns null for the removed annotation id', async () => {
|
|
120
|
+
await annotationFactory.create({
|
|
121
|
+
...params,
|
|
122
|
+
id: crypto.randomUUID(),
|
|
123
|
+
context: 'summary',
|
|
124
|
+
body: 'remove me',
|
|
125
|
+
bodyBytes: Buffer.byteLength('remove me'),
|
|
126
|
+
sequence: 1,
|
|
127
|
+
createdAt: new Date(),
|
|
128
|
+
updatedAt: new Date(),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const result = await writeAnnotations({
|
|
132
|
+
...params,
|
|
133
|
+
operations: [{context: 'summary', style: 'default', op: 'remove'}],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const rows = await db()
|
|
137
|
+
.select()
|
|
138
|
+
.from(annotations)
|
|
139
|
+
.where(eq(annotations.jobExecutionId, params.jobExecutionId));
|
|
140
|
+
expect(rows).toHaveLength(0);
|
|
141
|
+
expect(result).toEqual({
|
|
142
|
+
annotations: [{context: 'summary', id: null}],
|
|
143
|
+
accounting: {annotationCount: 0, totalBodyBytes: 0},
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('assigns sequence only on first create of a context', async () => {
|
|
148
|
+
const result = await writeAnnotations({
|
|
149
|
+
...params,
|
|
150
|
+
operations: [
|
|
151
|
+
{context: 'first', style: 'default', op: 'replace', body: 'one'},
|
|
152
|
+
{context: 'second', style: 'default', op: 'replace', body: 'two'},
|
|
153
|
+
{context: 'first', style: 'default', op: 'append', body: ' more'},
|
|
154
|
+
],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const rows = await db()
|
|
158
|
+
.select()
|
|
159
|
+
.from(annotations)
|
|
160
|
+
.where(eq(annotations.jobExecutionId, params.jobExecutionId));
|
|
161
|
+
expect(rows).toHaveLength(2);
|
|
162
|
+
expect(rows.find((row) => row.context === 'first')).toMatchObject({
|
|
163
|
+
body: 'one more',
|
|
164
|
+
sequence: 1,
|
|
165
|
+
});
|
|
166
|
+
expect(rows.find((row) => row.context === 'second')).toMatchObject({sequence: 2});
|
|
167
|
+
expect(result.accounting).toEqual({annotationCount: 2, totalBodyBytes: 11});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('rejects an annotation body over the byte budget', async () => {
|
|
171
|
+
const body = 'x'.repeat(1048577);
|
|
172
|
+
|
|
173
|
+
const write = writeAnnotations({
|
|
174
|
+
...params,
|
|
175
|
+
operations: [{context: 'huge', style: 'default', op: 'replace', body}],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await expect(write).rejects.toBeInstanceOf(AnnotationBodyTooLargeError);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('rejects an execution over the annotation count budget', async () => {
|
|
182
|
+
const operations = Array.from({length: 51}, (_, index) => ({
|
|
183
|
+
context: `context-${index}`,
|
|
184
|
+
style: 'default' as const,
|
|
185
|
+
op: 'replace' as const,
|
|
186
|
+
body: 'x',
|
|
187
|
+
}));
|
|
188
|
+
|
|
189
|
+
const write = writeAnnotations({...params, operations});
|
|
190
|
+
|
|
191
|
+
await expect(write).rejects.toBeInstanceOf(AnnotationCountLimitExceededError);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('rejects an execution over the total byte budget', async () => {
|
|
195
|
+
const operations = Array.from({length: 5}, (_, index) => ({
|
|
196
|
+
context: `context-${index}`,
|
|
197
|
+
style: 'default' as const,
|
|
198
|
+
op: 'replace' as const,
|
|
199
|
+
body: 'x'.repeat(900000),
|
|
200
|
+
}));
|
|
201
|
+
|
|
202
|
+
const write = writeAnnotations({...params, operations});
|
|
203
|
+
|
|
204
|
+
await expect(write).rejects.toBeInstanceOf(AnnotationTotalBytesLimitExceededError);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('rolls back the whole request when one operation exceeds a budget', async () => {
|
|
208
|
+
const write = writeAnnotations({
|
|
209
|
+
...params,
|
|
210
|
+
operations: [
|
|
211
|
+
{context: 'created-first', style: 'default', op: 'replace', body: 'ok'},
|
|
212
|
+
{
|
|
213
|
+
context: 'huge',
|
|
214
|
+
style: 'default',
|
|
215
|
+
op: 'replace',
|
|
216
|
+
body: 'x'.repeat(1048577),
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await expect(write).rejects.toBeInstanceOf(AnnotationBodyTooLargeError);
|
|
222
|
+
|
|
223
|
+
const rows = await db()
|
|
224
|
+
.select()
|
|
225
|
+
.from(annotations)
|
|
226
|
+
.where(eq(annotations.jobExecutionId, params.jobExecutionId));
|
|
227
|
+
expect(rows).toHaveLength(0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('waits for the per-execution advisory transaction lock before writing', async () => {
|
|
231
|
+
let releaseLock!: () => void;
|
|
232
|
+
const lockReleased = new Promise<void>((resolve) => {
|
|
233
|
+
releaseLock = resolve;
|
|
234
|
+
});
|
|
235
|
+
let lockAcquired!: () => void;
|
|
236
|
+
const lockReady = new Promise<void>((resolve) => {
|
|
237
|
+
lockAcquired = resolve;
|
|
238
|
+
});
|
|
239
|
+
const blocker = withAnnotationLock(params.jobExecutionId, async () => {
|
|
240
|
+
lockAcquired();
|
|
241
|
+
await lockReleased;
|
|
242
|
+
});
|
|
243
|
+
await lockReady;
|
|
244
|
+
|
|
245
|
+
const write = writeAnnotations({
|
|
246
|
+
...params,
|
|
247
|
+
operations: [{context: 'blocked', style: 'default', op: 'replace', body: 'after'}],
|
|
248
|
+
});
|
|
249
|
+
const stateBeforeRelease = await Promise.race([
|
|
250
|
+
write.then(() => 'completed'),
|
|
251
|
+
new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 50)),
|
|
252
|
+
]);
|
|
253
|
+
releaseLock();
|
|
254
|
+
await blocker;
|
|
255
|
+
|
|
256
|
+
const result = await write;
|
|
257
|
+
|
|
258
|
+
expect(stateBeforeRelease).toBe('blocked');
|
|
259
|
+
expect(result.accounting).toEqual({annotationCount: 1, totalBodyBytes: 5});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type {LeasedWriteAnnotationOperationDto} from '@shipfox/annotations-dto';
|
|
2
|
+
import {config} from '#config.js';
|
|
3
|
+
import {type StoredAnnotation, withAnnotationLock} from '#db/annotations.js';
|
|
4
|
+
import {
|
|
5
|
+
AnnotationBodyTooLargeError,
|
|
6
|
+
AnnotationCountLimitExceededError,
|
|
7
|
+
AnnotationTotalBytesLimitExceededError,
|
|
8
|
+
} from './errors.js';
|
|
9
|
+
|
|
10
|
+
export interface WriteAnnotationsParams {
|
|
11
|
+
workspaceId: string;
|
|
12
|
+
projectId: string;
|
|
13
|
+
workflowRunId: string;
|
|
14
|
+
workflowRunAttempt: number;
|
|
15
|
+
workflowRunAttemptId: string;
|
|
16
|
+
jobId: string;
|
|
17
|
+
jobExecutionId: string;
|
|
18
|
+
originStepId: string;
|
|
19
|
+
originStepAttempt: number;
|
|
20
|
+
operations: readonly LeasedWriteAnnotationOperationDto[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WriteAnnotationsResult {
|
|
24
|
+
annotations: Array<{context: string; id: string | null}>;
|
|
25
|
+
accounting: {
|
|
26
|
+
annotationCount: number;
|
|
27
|
+
totalBodyBytes: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function writeAnnotations(params: WriteAnnotationsParams): Promise<WriteAnnotationsResult> {
|
|
32
|
+
return withAnnotationLock(params.jobExecutionId, async (repo) => {
|
|
33
|
+
const current = await repo.loadCurrentAnnotations(params.jobExecutionId);
|
|
34
|
+
let nextSequence =
|
|
35
|
+
Math.max(0, ...Array.from(current.values()).map((annotation) => annotation.sequence)) + 1;
|
|
36
|
+
const results: WriteAnnotationsResult['annotations'] = [];
|
|
37
|
+
|
|
38
|
+
for (const operation of params.operations) {
|
|
39
|
+
if (operation.op === 'remove') {
|
|
40
|
+
await repo.removeAnnotation(params.jobExecutionId, operation.context);
|
|
41
|
+
current.delete(operation.context);
|
|
42
|
+
results.push({context: operation.context, id: null});
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const existing = current.get(operation.context);
|
|
47
|
+
const body =
|
|
48
|
+
operation.op === 'append' ? `${existing?.body ?? ''}${operation.body}` : operation.body;
|
|
49
|
+
const bodyBytes = Buffer.byteLength(body);
|
|
50
|
+
ensureBodyBudget(bodyBytes);
|
|
51
|
+
|
|
52
|
+
const isUnchangedReplace =
|
|
53
|
+
operation.op === 'replace' &&
|
|
54
|
+
existing !== undefined &&
|
|
55
|
+
existing.body === body &&
|
|
56
|
+
existing.style === operation.style;
|
|
57
|
+
if (isUnchangedReplace) {
|
|
58
|
+
results.push({context: operation.context, id: existing.id});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const draft = new Map(current);
|
|
63
|
+
draft.set(operation.context, {
|
|
64
|
+
id: existing?.id ?? '',
|
|
65
|
+
context: operation.context,
|
|
66
|
+
style: operation.style,
|
|
67
|
+
body,
|
|
68
|
+
bodyBytes,
|
|
69
|
+
sequence: existing?.sequence ?? nextSequence,
|
|
70
|
+
});
|
|
71
|
+
ensureExecutionBudgets(draft);
|
|
72
|
+
|
|
73
|
+
const row = existing
|
|
74
|
+
? await repo.updateAnnotation({
|
|
75
|
+
id: existing.id,
|
|
76
|
+
originStepId: params.originStepId,
|
|
77
|
+
originStepAttempt: params.originStepAttempt,
|
|
78
|
+
style: operation.style,
|
|
79
|
+
body,
|
|
80
|
+
bodyBytes,
|
|
81
|
+
})
|
|
82
|
+
: await repo.createAnnotation({
|
|
83
|
+
workspaceId: params.workspaceId,
|
|
84
|
+
projectId: params.projectId,
|
|
85
|
+
workflowRunId: params.workflowRunId,
|
|
86
|
+
workflowRunAttempt: params.workflowRunAttempt,
|
|
87
|
+
workflowRunAttemptId: params.workflowRunAttemptId,
|
|
88
|
+
jobId: params.jobId,
|
|
89
|
+
jobExecutionId: params.jobExecutionId,
|
|
90
|
+
originStepId: params.originStepId,
|
|
91
|
+
originStepAttempt: params.originStepAttempt,
|
|
92
|
+
context: operation.context,
|
|
93
|
+
style: operation.style,
|
|
94
|
+
body,
|
|
95
|
+
bodyBytes,
|
|
96
|
+
sequence: nextSequence,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
current.set(operation.context, {
|
|
100
|
+
id: row.id,
|
|
101
|
+
context: row.context,
|
|
102
|
+
style: row.style,
|
|
103
|
+
body: row.body,
|
|
104
|
+
bodyBytes: row.bodyBytes,
|
|
105
|
+
sequence: row.sequence,
|
|
106
|
+
});
|
|
107
|
+
if (!existing) nextSequence += 1;
|
|
108
|
+
results.push({context: operation.context, id: row.id});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
annotations: results,
|
|
113
|
+
accounting: currentAccounting(current),
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function ensureBodyBudget(bodyBytes: number): void {
|
|
119
|
+
if (bodyBytes > config.ANNOTATIONS_MAX_BODY_BYTES) {
|
|
120
|
+
throw new AnnotationBodyTooLargeError(config.ANNOTATIONS_MAX_BODY_BYTES);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ensureExecutionBudgets(annotationsByContext: ReadonlyMap<string, StoredAnnotation>): void {
|
|
125
|
+
const accounting = currentAccounting(annotationsByContext);
|
|
126
|
+
if (accounting.annotationCount > config.ANNOTATIONS_MAX_PER_EXECUTION) {
|
|
127
|
+
throw new AnnotationCountLimitExceededError(config.ANNOTATIONS_MAX_PER_EXECUTION);
|
|
128
|
+
}
|
|
129
|
+
if (accounting.totalBodyBytes > config.ANNOTATIONS_MAX_TOTAL_BYTES) {
|
|
130
|
+
throw new AnnotationTotalBytesLimitExceededError(config.ANNOTATIONS_MAX_TOTAL_BYTES);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function currentAccounting(annotationsByContext: ReadonlyMap<string, StoredAnnotation>) {
|
|
135
|
+
return {
|
|
136
|
+
annotationCount: annotationsByContext.size,
|
|
137
|
+
totalBodyBytes: Array.from(annotationsByContext.values()).reduce(
|
|
138
|
+
(total, annotation) => total + annotation.bodyBytes,
|
|
139
|
+
0,
|
|
140
|
+
),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import {annotationFactory} from '#test/index.js';
|
|
2
|
+
import {listAnnotationsForRunAttempt} from './annotations.js';
|
|
3
|
+
|
|
4
|
+
describe('listAnnotationsForRunAttempt', () => {
|
|
5
|
+
it('lists annotations for the requested run attempt and workspace in sequence order', async () => {
|
|
6
|
+
const workspaceId = crypto.randomUUID();
|
|
7
|
+
const workflowRunId = crypto.randomUUID();
|
|
8
|
+
const [second, first] = await Promise.all([
|
|
9
|
+
annotationFactory.create({
|
|
10
|
+
workspaceId,
|
|
11
|
+
workflowRunId,
|
|
12
|
+
workflowRunAttempt: 2,
|
|
13
|
+
jobExecutionId: crypto.randomUUID(),
|
|
14
|
+
context: 'a-second',
|
|
15
|
+
sequence: 2,
|
|
16
|
+
}),
|
|
17
|
+
annotationFactory.create({
|
|
18
|
+
workspaceId,
|
|
19
|
+
workflowRunId,
|
|
20
|
+
workflowRunAttempt: 2,
|
|
21
|
+
jobExecutionId: crypto.randomUUID(),
|
|
22
|
+
context: 'z-first',
|
|
23
|
+
sequence: 1,
|
|
24
|
+
}),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const result = await listAnnotationsForRunAttempt({
|
|
28
|
+
workflowRunId,
|
|
29
|
+
workflowRunAttempt: 2,
|
|
30
|
+
workspaceIds: [workspaceId],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(result).toEqual({annotations: [first, second], hasMore: false, nextCursor: null});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('excludes annotations from other run attempts, runs, and workspaces', async () => {
|
|
37
|
+
const workspaceId = crypto.randomUUID();
|
|
38
|
+
const workflowRunId = crypto.randomUUID();
|
|
39
|
+
const visible = await annotationFactory.create({
|
|
40
|
+
workspaceId,
|
|
41
|
+
workflowRunId,
|
|
42
|
+
workflowRunAttempt: 1,
|
|
43
|
+
});
|
|
44
|
+
await annotationFactory.create({
|
|
45
|
+
workspaceId,
|
|
46
|
+
workflowRunId,
|
|
47
|
+
workflowRunAttempt: 2,
|
|
48
|
+
jobExecutionId: crypto.randomUUID(),
|
|
49
|
+
context: 'other-attempt',
|
|
50
|
+
});
|
|
51
|
+
await annotationFactory.create({
|
|
52
|
+
workspaceId,
|
|
53
|
+
workflowRunId: crypto.randomUUID(),
|
|
54
|
+
jobExecutionId: crypto.randomUUID(),
|
|
55
|
+
context: 'other-run',
|
|
56
|
+
});
|
|
57
|
+
await annotationFactory.create({
|
|
58
|
+
workspaceId: crypto.randomUUID(),
|
|
59
|
+
workflowRunId,
|
|
60
|
+
jobExecutionId: crypto.randomUUID(),
|
|
61
|
+
context: 'other-workspace',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const result = await listAnnotationsForRunAttempt({
|
|
65
|
+
workflowRunId,
|
|
66
|
+
workflowRunAttempt: 1,
|
|
67
|
+
workspaceIds: [workspaceId],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(result).toEqual({annotations: [visible], hasMore: false, nextCursor: null});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('filters by job execution when provided', async () => {
|
|
74
|
+
const workspaceId = crypto.randomUUID();
|
|
75
|
+
const workflowRunId = crypto.randomUUID();
|
|
76
|
+
const jobExecutionId = crypto.randomUUID();
|
|
77
|
+
const visible = await annotationFactory.create({
|
|
78
|
+
workspaceId,
|
|
79
|
+
workflowRunId,
|
|
80
|
+
jobExecutionId,
|
|
81
|
+
context: 'visible',
|
|
82
|
+
});
|
|
83
|
+
await annotationFactory.create({
|
|
84
|
+
workspaceId,
|
|
85
|
+
workflowRunId,
|
|
86
|
+
jobExecutionId: crypto.randomUUID(),
|
|
87
|
+
context: 'hidden',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const result = await listAnnotationsForRunAttempt({
|
|
91
|
+
workflowRunId,
|
|
92
|
+
workflowRunAttempt: 1,
|
|
93
|
+
workspaceIds: [workspaceId],
|
|
94
|
+
jobExecutionId,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(result).toEqual({annotations: [visible], hasMore: false, nextCursor: null});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('limits returned annotations and reports more rows', async () => {
|
|
101
|
+
const workspaceId = crypto.randomUUID();
|
|
102
|
+
const workflowRunId = crypto.randomUUID();
|
|
103
|
+
const [first] = await Promise.all([
|
|
104
|
+
annotationFactory.create({
|
|
105
|
+
workspaceId,
|
|
106
|
+
workflowRunId,
|
|
107
|
+
jobExecutionId: crypto.randomUUID(),
|
|
108
|
+
context: 'first',
|
|
109
|
+
sequence: 1,
|
|
110
|
+
}),
|
|
111
|
+
annotationFactory.create({
|
|
112
|
+
workspaceId,
|
|
113
|
+
workflowRunId,
|
|
114
|
+
jobExecutionId: crypto.randomUUID(),
|
|
115
|
+
context: 'second',
|
|
116
|
+
sequence: 2,
|
|
117
|
+
}),
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const result = await listAnnotationsForRunAttempt({
|
|
121
|
+
workflowRunId,
|
|
122
|
+
workflowRunAttempt: 1,
|
|
123
|
+
workspaceIds: [workspaceId],
|
|
124
|
+
limit: 1,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(result).toEqual({
|
|
128
|
+
annotations: [first],
|
|
129
|
+
hasMore: true,
|
|
130
|
+
nextCursor: {sequence: first.sequence, id: first.id},
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('continues listing after the sequence and id cursor', async () => {
|
|
135
|
+
const workspaceId = crypto.randomUUID();
|
|
136
|
+
const workflowRunId = crypto.randomUUID();
|
|
137
|
+
const [first, second, third] = await Promise.all([
|
|
138
|
+
annotationFactory.create({
|
|
139
|
+
workspaceId,
|
|
140
|
+
workflowRunId,
|
|
141
|
+
jobExecutionId: crypto.randomUUID(),
|
|
142
|
+
context: 'first',
|
|
143
|
+
sequence: 1,
|
|
144
|
+
}),
|
|
145
|
+
annotationFactory.create({
|
|
146
|
+
workspaceId,
|
|
147
|
+
workflowRunId,
|
|
148
|
+
jobExecutionId: crypto.randomUUID(),
|
|
149
|
+
context: 'second',
|
|
150
|
+
sequence: 2,
|
|
151
|
+
}),
|
|
152
|
+
annotationFactory.create({
|
|
153
|
+
workspaceId,
|
|
154
|
+
workflowRunId,
|
|
155
|
+
jobExecutionId: crypto.randomUUID(),
|
|
156
|
+
context: 'third',
|
|
157
|
+
sequence: 3,
|
|
158
|
+
}),
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
const firstPage = await listAnnotationsForRunAttempt({
|
|
162
|
+
workflowRunId,
|
|
163
|
+
workflowRunAttempt: 1,
|
|
164
|
+
workspaceIds: [workspaceId],
|
|
165
|
+
limit: 1,
|
|
166
|
+
});
|
|
167
|
+
const secondPage = await listAnnotationsForRunAttempt({
|
|
168
|
+
workflowRunId,
|
|
169
|
+
workflowRunAttempt: 1,
|
|
170
|
+
workspaceIds: [workspaceId],
|
|
171
|
+
after: firstPage.nextCursor ?? undefined,
|
|
172
|
+
limit: 1,
|
|
173
|
+
});
|
|
174
|
+
const thirdPage = await listAnnotationsForRunAttempt({
|
|
175
|
+
workflowRunId,
|
|
176
|
+
workflowRunAttempt: 1,
|
|
177
|
+
workspaceIds: [workspaceId],
|
|
178
|
+
after: secondPage.nextCursor ?? undefined,
|
|
179
|
+
limit: 1,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(firstPage).toEqual({
|
|
183
|
+
annotations: [first],
|
|
184
|
+
hasMore: true,
|
|
185
|
+
nextCursor: {sequence: first.sequence, id: first.id},
|
|
186
|
+
});
|
|
187
|
+
expect(secondPage).toEqual({
|
|
188
|
+
annotations: [second],
|
|
189
|
+
hasMore: true,
|
|
190
|
+
nextCursor: {sequence: second.sequence, id: second.id},
|
|
191
|
+
});
|
|
192
|
+
expect(thirdPage).toEqual({
|
|
193
|
+
annotations: [third],
|
|
194
|
+
hasMore: false,
|
|
195
|
+
nextCursor: null,
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('uses id as the cursor tie-breaker for duplicate sequences', async () => {
|
|
200
|
+
const workspaceId = crypto.randomUUID();
|
|
201
|
+
const workflowRunId = crypto.randomUUID();
|
|
202
|
+
const duplicates = await Promise.all([
|
|
203
|
+
annotationFactory.create({
|
|
204
|
+
workspaceId,
|
|
205
|
+
workflowRunId,
|
|
206
|
+
jobExecutionId: crypto.randomUUID(),
|
|
207
|
+
context: 'same-sequence-a',
|
|
208
|
+
sequence: 1,
|
|
209
|
+
}),
|
|
210
|
+
annotationFactory.create({
|
|
211
|
+
workspaceId,
|
|
212
|
+
workflowRunId,
|
|
213
|
+
jobExecutionId: crypto.randomUUID(),
|
|
214
|
+
context: 'same-sequence-b',
|
|
215
|
+
sequence: 1,
|
|
216
|
+
}),
|
|
217
|
+
]);
|
|
218
|
+
const ordered = [...duplicates].sort((left, right) => left.id.localeCompare(right.id));
|
|
219
|
+
const first = ordered[0];
|
|
220
|
+
const second = ordered[1];
|
|
221
|
+
if (!first || !second) throw new Error('Expected duplicate annotation fixtures');
|
|
222
|
+
|
|
223
|
+
const firstPage = await listAnnotationsForRunAttempt({
|
|
224
|
+
workflowRunId,
|
|
225
|
+
workflowRunAttempt: 1,
|
|
226
|
+
workspaceIds: [workspaceId],
|
|
227
|
+
limit: 1,
|
|
228
|
+
});
|
|
229
|
+
const secondPage = await listAnnotationsForRunAttempt({
|
|
230
|
+
workflowRunId,
|
|
231
|
+
workflowRunAttempt: 1,
|
|
232
|
+
workspaceIds: [workspaceId],
|
|
233
|
+
after: firstPage.nextCursor ?? undefined,
|
|
234
|
+
limit: 1,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(firstPage).toEqual({
|
|
238
|
+
annotations: [first],
|
|
239
|
+
hasMore: true,
|
|
240
|
+
nextCursor: {sequence: first.sequence, id: first.id},
|
|
241
|
+
});
|
|
242
|
+
expect(secondPage).toEqual({
|
|
243
|
+
annotations: [second],
|
|
244
|
+
hasMore: false,
|
|
245
|
+
nextCursor: null,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('returns an empty list when the user has no workspace memberships', async () => {
|
|
250
|
+
const workflowRunId = crypto.randomUUID();
|
|
251
|
+
await annotationFactory.create({workflowRunId});
|
|
252
|
+
|
|
253
|
+
const result = await listAnnotationsForRunAttempt({
|
|
254
|
+
workflowRunId,
|
|
255
|
+
workflowRunAttempt: 1,
|
|
256
|
+
workspaceIds: [],
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
expect(result).toEqual({annotations: [], hasMore: false, nextCursor: null});
|
|
260
|
+
});
|
|
261
|
+
});
|