@cursor/july 0.1.23 → 0.1.25
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 +8 -1
- package/src/artifacts.ts +78 -0
- package/src/channels/github/github.test.ts +6 -0
- package/src/evals/assertions.test.ts +37 -0
- package/src/evals/assertions.ts +37 -1
- package/src/evals.ts +1 -0
- package/src/index.ts +1 -0
- package/src/internal/artifacts-store.test.ts +283 -0
- package/src/internal/artifacts-store.ts +290 -0
- package/src/internal/builtin-tools/artifacts.test.ts +247 -0
- package/src/internal/builtin-tools/artifacts.ts +74 -0
- package/src/internal/builtin-tools/index.ts +21 -0
- package/src/internal/cli-slack.test.ts +8 -1
- package/src/internal/cli-slack.ts +13 -4
- package/src/internal/discovery.artifact-tool.test.ts +136 -0
- package/src/internal/discovery.artifacts.test.ts +119 -0
- package/src/internal/discovery.ts +102 -1
- package/src/internal/distribution.ts +1 -0
- package/src/internal/handleAgentServeTrigger.test.ts +6 -0
- package/src/internal/server.artifacts.test.ts +303 -0
- package/src/internal/server.ts +43 -0
- package/src/internal/session-engine.artifacts.test.ts +243 -0
- package/src/internal/session-engine.ts +125 -0
- package/src/internal/storage-coordinator.ts +62 -0
- package/src/storage.ts +6 -0
- package/src/types.ts +134 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cursor/july",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
4
4
|
"description": "(early alpha) Filesystem-first framework for defining Cursor agents as markdown and TypeScript and serving them over channels with the Cursor SDK.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"repository": {
|
|
@@ -147,6 +147,13 @@
|
|
|
147
147
|
"import": "./dist/ab.js",
|
|
148
148
|
"default": "./dist/ab.js"
|
|
149
149
|
},
|
|
150
|
+
"./artifacts": {
|
|
151
|
+
"anysphere-source": "./src/artifacts.ts",
|
|
152
|
+
"bun": "./src/artifacts.ts",
|
|
153
|
+
"types": "./dist/artifacts.d.ts",
|
|
154
|
+
"import": "./dist/artifacts.js",
|
|
155
|
+
"default": "./dist/artifacts.js"
|
|
156
|
+
},
|
|
150
157
|
"./storage": {
|
|
151
158
|
"anysphere-source": "./src/storage.ts",
|
|
152
159
|
"bun": "./src/storage.ts",
|
package/src/artifacts.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineArtifacts` — authored at `agent/artifacts.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Artifacts mark durable outputs the agent produced (reviewed PR URLs,
|
|
5
|
+
* reports, …) so they can be listed across sessions and rendered in the
|
|
6
|
+
* playground. Declare artifact *kinds* here; host code tags via
|
|
7
|
+
* `ctx.artifacts.tag(...)` (tools, hooks, and channel handlers all carry
|
|
8
|
+
* an {@link ArtifactsApi}), and `agentTool: true` exposes the model-facing
|
|
9
|
+
* `tag_artifact` built-in tool generated from the same kinds registry.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { z } from "zod";
|
|
13
|
+
* import { defineArtifacts } from "@anysphere/agent-serve/artifacts";
|
|
14
|
+
*
|
|
15
|
+
* export default defineArtifacts({
|
|
16
|
+
* kinds: {
|
|
17
|
+
* "reviewed-pr": {
|
|
18
|
+
* description: "A pull request this agent reviewed.",
|
|
19
|
+
* schema: z.object({ url: z.string(), verdict: z.string() }),
|
|
20
|
+
* },
|
|
21
|
+
* report: { description: "A generated report." },
|
|
22
|
+
* },
|
|
23
|
+
* agentTool: true,
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// TODO(artifacts-files): file/blob payloads land on the Agent Stores mount
|
|
29
|
+
// (any files, fuse-mounted everywhere, survives deploys) once it ships —
|
|
30
|
+
// same migration as TODO(agent-stores). Serve via GET
|
|
31
|
+
// /v1/artifacts/:id/content; cloud-runtime tags keep the VM-native
|
|
32
|
+
// /opt/cursor/artifacts + cursor.com artifact URLs.
|
|
33
|
+
|
|
34
|
+
import { brandDefinition } from "./internal/brand.js";
|
|
35
|
+
import type { ArtifactsConfig, ArtifactsDefinition } from "./types.js";
|
|
36
|
+
|
|
37
|
+
export type {
|
|
38
|
+
ArtifactKindConfig,
|
|
39
|
+
ArtifactListFilter,
|
|
40
|
+
ArtifactRecord,
|
|
41
|
+
ArtifactSource,
|
|
42
|
+
ArtifactsApi,
|
|
43
|
+
ArtifactsConfig,
|
|
44
|
+
ArtifactsDefinition,
|
|
45
|
+
ArtifactTagInput,
|
|
46
|
+
} from "./types.js";
|
|
47
|
+
|
|
48
|
+
/** Default {@link ArtifactsConfig.max} retention cap. */
|
|
49
|
+
export const ARTIFACTS_DEFAULT_MAX = 1_000;
|
|
50
|
+
|
|
51
|
+
export function defineArtifacts(config: ArtifactsConfig): ArtifactsDefinition {
|
|
52
|
+
for (const [kind, kindConfig] of Object.entries(config.kinds ?? {})) {
|
|
53
|
+
if (kind.trim() === "") {
|
|
54
|
+
throw new Error("defineArtifacts: kind names must be non-empty");
|
|
55
|
+
}
|
|
56
|
+
if (
|
|
57
|
+
typeof kindConfig.description !== "string" ||
|
|
58
|
+
kindConfig.description.trim() === ""
|
|
59
|
+
) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`defineArtifacts: kinds.${kind}.description must be a non-empty string`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (config.max !== undefined) {
|
|
66
|
+
if (!Number.isInteger(config.max) || config.max < 1) {
|
|
67
|
+
throw new Error("defineArtifacts: max must be a positive integer");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return brandDefinition("artifacts", config);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Effective retention cap for a config (default {@link ARTIFACTS_DEFAULT_MAX}). */
|
|
74
|
+
export function resolveArtifactsMax(
|
|
75
|
+
config: ArtifactsConfig | undefined
|
|
76
|
+
): number {
|
|
77
|
+
return config?.max ?? ARTIFACTS_DEFAULT_MAX;
|
|
78
|
+
}
|
|
@@ -787,6 +787,12 @@ function handlerArgs(send: SendMessageFn): ChannelHandlerArgs {
|
|
|
787
787
|
delete: async () => {},
|
|
788
788
|
},
|
|
789
789
|
},
|
|
790
|
+
artifacts: {
|
|
791
|
+
tag: async () => {
|
|
792
|
+
throw new Error("artifacts not configured in test");
|
|
793
|
+
},
|
|
794
|
+
list: async () => [],
|
|
795
|
+
},
|
|
790
796
|
send,
|
|
791
797
|
getSession: async () => null,
|
|
792
798
|
receive: async () => fakeSession(),
|
|
@@ -210,6 +210,43 @@ describe("run-level assertions", () => {
|
|
|
210
210
|
);
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
+
it("taggedArtifact matches artifact.tagged events by kind and predicate", () => {
|
|
214
|
+
const { recorder, t } = setup([
|
|
215
|
+
...weatherRun(),
|
|
216
|
+
event("artifact.tagged", {
|
|
217
|
+
id: "art_1",
|
|
218
|
+
kind: "reviewed-pr",
|
|
219
|
+
key: "pr-1",
|
|
220
|
+
title: "PR #1",
|
|
221
|
+
data: { url: "https://github.com/org/repo/pull/1" },
|
|
222
|
+
source: "model",
|
|
223
|
+
}),
|
|
224
|
+
]);
|
|
225
|
+
t.taggedArtifact();
|
|
226
|
+
t.taggedArtifact("reviewed-pr");
|
|
227
|
+
t.taggedArtifact("report");
|
|
228
|
+
t.taggedArtifact("reviewed-pr", (record) => record.source === "model");
|
|
229
|
+
t.taggedArtifact("reviewed-pr", (record) => record.key === "other");
|
|
230
|
+
const results = recorder.results();
|
|
231
|
+
expect(results.map((r) => r.passed)).toEqual([
|
|
232
|
+
true,
|
|
233
|
+
true,
|
|
234
|
+
false,
|
|
235
|
+
true,
|
|
236
|
+
false,
|
|
237
|
+
]);
|
|
238
|
+
expect(results[1]?.name).toBe("taggedArtifact(reviewed-pr)");
|
|
239
|
+
expect(results[2]?.detail).toBe("tagged kinds: [reviewed-pr]");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("taggedArtifact fails when nothing was tagged", () => {
|
|
243
|
+
const { recorder, t } = setup();
|
|
244
|
+
t.taggedArtifact();
|
|
245
|
+
const [result] = recorder.results();
|
|
246
|
+
expect(result?.passed).toBe(false);
|
|
247
|
+
expect(result?.detail).toBe("tagged kinds: []");
|
|
248
|
+
});
|
|
249
|
+
|
|
213
250
|
it("notCalledTool matches any lifecycle state", () => {
|
|
214
251
|
const { recorder, t } = setup();
|
|
215
252
|
t.notCalledTool("echo");
|
package/src/evals/assertions.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* by `t.send(...)`, which narrows the events under inspection to that turn.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import type { SessionEvent } from "../types.js";
|
|
18
|
+
import type { SessionEvent, SessionEventPayload } from "../types.js";
|
|
19
19
|
import type { Expectation } from "./expect.js";
|
|
20
20
|
import { evaluateExpectation, expectationSeverity } from "./expect.js";
|
|
21
21
|
import type {
|
|
@@ -37,6 +37,12 @@ import { deriveRunFacts } from "./run-facts.js";
|
|
|
37
37
|
/** Hard gates fail the eval; soft assertions are tracked scores. */
|
|
38
38
|
export type EvalAssertionSeverity = "gate" | "soft";
|
|
39
39
|
|
|
40
|
+
/** One `artifact.tagged` payload, as seen by `t.taggedArtifact(...)`. */
|
|
41
|
+
export type EvalTaggedArtifactFact = Extract<
|
|
42
|
+
SessionEventPayload,
|
|
43
|
+
{ type: "artifact.tagged" }
|
|
44
|
+
>["data"];
|
|
45
|
+
|
|
40
46
|
/**
|
|
41
47
|
* Overall grade for one eval case.
|
|
42
48
|
*
|
|
@@ -314,6 +320,14 @@ export interface EvalAssertions {
|
|
|
314
320
|
name: string,
|
|
315
321
|
matcher?: EvalSubagentMatcher
|
|
316
322
|
): EvalAssertionHandle;
|
|
323
|
+
/**
|
|
324
|
+
* Gate: at least one artifact was tagged (`ctx.artifacts.tag` or the
|
|
325
|
+
* `tag_artifact` built-in), optionally of `kind` and matching `predicate`.
|
|
326
|
+
*/
|
|
327
|
+
taggedArtifact(
|
|
328
|
+
kind?: string,
|
|
329
|
+
predicate?: (record: EvalTaggedArtifactFact) => boolean
|
|
330
|
+
): EvalAssertionHandle;
|
|
317
331
|
/** Gate: at least one matching event of `type` occurred. */
|
|
318
332
|
event(type: string, matcher?: EvalEventMatcher): EvalAssertionHandle;
|
|
319
333
|
/** Gate: no matching event of `type` occurred. */
|
|
@@ -567,6 +581,28 @@ export function createAssertions(
|
|
|
567
581
|
});
|
|
568
582
|
},
|
|
569
583
|
|
|
584
|
+
taggedArtifact(
|
|
585
|
+
kind?: string,
|
|
586
|
+
predicate?: (record: EvalTaggedArtifactFact) => boolean
|
|
587
|
+
) {
|
|
588
|
+
const tagged = events().flatMap((event) =>
|
|
589
|
+
event.type === "artifact.tagged" ? [event.data] : []
|
|
590
|
+
);
|
|
591
|
+
const matched = tagged.filter(
|
|
592
|
+
(record) =>
|
|
593
|
+
(kind === undefined || record.kind === kind) &&
|
|
594
|
+
(predicate === undefined || predicate(record) === true)
|
|
595
|
+
);
|
|
596
|
+
return record({
|
|
597
|
+
name: `taggedArtifact(${kind ?? ""})`,
|
|
598
|
+
passed: matched.length > 0,
|
|
599
|
+
detail:
|
|
600
|
+
matched.length > 0
|
|
601
|
+
? undefined
|
|
602
|
+
: `tagged kinds: [${tagged.map((r) => r.kind).join(", ")}]`,
|
|
603
|
+
});
|
|
604
|
+
},
|
|
605
|
+
|
|
570
606
|
event(type: string, matcher?: EvalEventMatcher) {
|
|
571
607
|
const matched = matchingEvents(type, matcher);
|
|
572
608
|
const passed = matchCount(matched.length, matcher?.count);
|
package/src/evals.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { defineArtifacts } from "../artifacts.js";
|
|
7
|
+
import { defineStorage, storageKeys } from "../storage.js";
|
|
8
|
+
import type { JsonValue } from "../types.js";
|
|
9
|
+
import { ArtifactsStore, artifactIdForKey } from "./artifacts-store.js";
|
|
10
|
+
import { StorageCoordinator } from "./storage-coordinator.js";
|
|
11
|
+
|
|
12
|
+
const AGENT = "artifacts-test";
|
|
13
|
+
|
|
14
|
+
const cleanups: Array<() => Promise<void>> = [];
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
while (cleanups.length > 0) {
|
|
18
|
+
await cleanups.pop()?.();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
async function tempStateRoot(): Promise<string> {
|
|
23
|
+
const dir = await mkdtemp(join(tmpdir(), "agent-serve-artifacts-"));
|
|
24
|
+
cleanups.push(() => rm(dir, { recursive: true, force: true }));
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Distinct-millisecond updatedAt stamps for eviction/order assertions. */
|
|
29
|
+
function tick(): Promise<void> {
|
|
30
|
+
return new Promise((resolve) => setTimeout(resolve, 3));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("ArtifactsStore", () => {
|
|
34
|
+
it("upserts by key: same key ⇒ same id, replaced row, preserved createdAt", async () => {
|
|
35
|
+
const store = new ArtifactsStore({
|
|
36
|
+
stateRoot: await tempStateRoot(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const first = await store.tag({
|
|
40
|
+
kind: "report",
|
|
41
|
+
key: "weekly",
|
|
42
|
+
data: { rev: 1 },
|
|
43
|
+
});
|
|
44
|
+
await tick();
|
|
45
|
+
const second = await store.tag({
|
|
46
|
+
kind: "report",
|
|
47
|
+
key: "weekly",
|
|
48
|
+
data: { rev: 2 },
|
|
49
|
+
title: "Weekly report",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(second.id).toBe(first.id);
|
|
53
|
+
expect(second.id).toBe(artifactIdForKey("weekly"));
|
|
54
|
+
expect(second.createdAt).toBe(first.createdAt);
|
|
55
|
+
expect(second.updatedAt > first.updatedAt).toBe(true);
|
|
56
|
+
|
|
57
|
+
const rows = await store.list();
|
|
58
|
+
expect(rows).toHaveLength(1);
|
|
59
|
+
expect(rows[0]?.data).toEqual({ rev: 2 });
|
|
60
|
+
expect(rows[0]?.title).toBe("Weekly report");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("gives keyless tags fresh random ids", async () => {
|
|
64
|
+
const store = new ArtifactsStore({
|
|
65
|
+
stateRoot: await tempStateRoot(),
|
|
66
|
+
});
|
|
67
|
+
const a = await store.tag({ data: 1 });
|
|
68
|
+
const b = await store.tag({ data: 2 });
|
|
69
|
+
expect(a.id).not.toBe(b.id);
|
|
70
|
+
expect(await store.list()).toHaveLength(2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("validates data against the declared kind schema", async () => {
|
|
74
|
+
const store = new ArtifactsStore({
|
|
75
|
+
stateRoot: await tempStateRoot(),
|
|
76
|
+
config: defineArtifacts({
|
|
77
|
+
kinds: {
|
|
78
|
+
"reviewed-pr": {
|
|
79
|
+
description: "A reviewed PR.",
|
|
80
|
+
schema: z.object({ url: z.string() }),
|
|
81
|
+
},
|
|
82
|
+
note: { description: "Schemaless note." },
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const ok = await store.tag({
|
|
88
|
+
kind: "reviewed-pr",
|
|
89
|
+
data: { url: "https://github.com/org/repo/pull/1" },
|
|
90
|
+
});
|
|
91
|
+
expect(ok.kind).toBe("reviewed-pr");
|
|
92
|
+
|
|
93
|
+
await expect(
|
|
94
|
+
store.tag({ kind: "reviewed-pr", data: { nope: true } })
|
|
95
|
+
).rejects.toThrow(/does not match the "reviewed-pr" schema/);
|
|
96
|
+
|
|
97
|
+
// A kind without a schema accepts any payload.
|
|
98
|
+
await expect(
|
|
99
|
+
store.tag({ kind: "note", data: "free text" })
|
|
100
|
+
).resolves.toMatchObject({ kind: "note" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("persists the schema's parsed output (defaults applied)", async () => {
|
|
104
|
+
const store = new ArtifactsStore({
|
|
105
|
+
stateRoot: await tempStateRoot(),
|
|
106
|
+
config: defineArtifacts({
|
|
107
|
+
kinds: {
|
|
108
|
+
report: {
|
|
109
|
+
description: "Report.",
|
|
110
|
+
schema: z.object({
|
|
111
|
+
name: z.string(),
|
|
112
|
+
status: z.string().default("draft"),
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
const record = await store.tag({ kind: "report", data: { name: "q3" } });
|
|
119
|
+
expect(record.data).toEqual({ name: "q3", status: "draft" });
|
|
120
|
+
expect((await store.list())[0]?.data).toEqual({
|
|
121
|
+
name: "q3",
|
|
122
|
+
status: "draft",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("rejects unknown kinds when kinds are declared", async () => {
|
|
127
|
+
const store = new ArtifactsStore({
|
|
128
|
+
stateRoot: await tempStateRoot(),
|
|
129
|
+
config: defineArtifacts({
|
|
130
|
+
kinds: { note: { description: "Note." } },
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
await expect(store.tag({ kind: "mystery", data: 1 })).rejects.toThrow(
|
|
134
|
+
/unknown kind "mystery" \(declared: note\)/
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('accepts freeform kinds (default "artifact") with zero config', async () => {
|
|
139
|
+
const store = new ArtifactsStore({
|
|
140
|
+
stateRoot: await tempStateRoot(),
|
|
141
|
+
});
|
|
142
|
+
const defaulted = await store.tag({ data: { anything: true } });
|
|
143
|
+
expect(defaulted.kind).toBe("artifact");
|
|
144
|
+
expect(defaulted.source).toBe("host");
|
|
145
|
+
const custom = await store.tag({ kind: "whatever", data: null });
|
|
146
|
+
expect(custom.kind).toBe("whatever");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("evicts the oldest-updated row past the max cap", async () => {
|
|
150
|
+
const store = new ArtifactsStore({
|
|
151
|
+
stateRoot: await tempStateRoot(),
|
|
152
|
+
config: defineArtifacts({ max: 2 }),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await store.tag({ key: "a", data: "a" });
|
|
156
|
+
await tick();
|
|
157
|
+
await store.tag({ key: "b", data: "b" });
|
|
158
|
+
await tick();
|
|
159
|
+
// Refreshing "a" makes "b" the oldest-updated row.
|
|
160
|
+
await store.tag({ key: "a", data: "a2" });
|
|
161
|
+
await tick();
|
|
162
|
+
await store.tag({ key: "c", data: "c" });
|
|
163
|
+
|
|
164
|
+
const keys = (await store.list()).map((row) => row.key);
|
|
165
|
+
expect(keys.sort()).toEqual(["a", "c"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("keeps exactly max rows under concurrent inserts", async () => {
|
|
169
|
+
const store = new ArtifactsStore({
|
|
170
|
+
stateRoot: await tempStateRoot(),
|
|
171
|
+
config: defineArtifacts({ max: 3 }),
|
|
172
|
+
});
|
|
173
|
+
await Promise.all(
|
|
174
|
+
Array.from({ length: 8 }, (_, i) => store.tag({ data: i }))
|
|
175
|
+
);
|
|
176
|
+
expect(await store.list()).toHaveLength(3);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("skips corrupt row files instead of failing list", async () => {
|
|
180
|
+
const stateRoot = await tempStateRoot();
|
|
181
|
+
const store = new ArtifactsStore({ stateRoot });
|
|
182
|
+
await store.tag({ key: "good", data: 1 });
|
|
183
|
+
await writeFile(
|
|
184
|
+
join(stateRoot, "artifacts", "corrupt.json"),
|
|
185
|
+
"{not json",
|
|
186
|
+
"utf8"
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const rows = await store.list();
|
|
190
|
+
expect(rows).toHaveLength(1);
|
|
191
|
+
expect(rows[0]?.key).toBe("good");
|
|
192
|
+
// Inserts (and their eviction scan) keep working too.
|
|
193
|
+
await expect(store.tag({ key: "more", data: 2 })).resolves.toBeDefined();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("filters list by kind and sessionId, newest-updated first", async () => {
|
|
197
|
+
const store = new ArtifactsStore({
|
|
198
|
+
stateRoot: await tempStateRoot(),
|
|
199
|
+
});
|
|
200
|
+
await store.tag({ kind: "report", data: 1, sessionId: "ses_1" });
|
|
201
|
+
await tick();
|
|
202
|
+
await store.tag({ kind: "report", data: 2, sessionId: "ses_2" });
|
|
203
|
+
await tick();
|
|
204
|
+
await store.tag({ kind: "note", data: 3, sessionId: "ses_1" });
|
|
205
|
+
|
|
206
|
+
expect(await store.list({ kind: "report" })).toHaveLength(2);
|
|
207
|
+
expect(await store.list({ sessionId: "ses_1" })).toHaveLength(2);
|
|
208
|
+
expect(
|
|
209
|
+
await store.list({ kind: "report", sessionId: "ses_1" })
|
|
210
|
+
).toHaveLength(1);
|
|
211
|
+
|
|
212
|
+
const all = await store.list();
|
|
213
|
+
expect(all.map((row) => row.data)).toEqual([3, 2, 1]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("persists rows through defineStorage under storageKeys.artifact", async () => {
|
|
217
|
+
const backing = new Map<string, JsonValue>();
|
|
218
|
+
const coordinator = new StorageCoordinator({
|
|
219
|
+
definition: defineStorage({
|
|
220
|
+
put: (key, value) => {
|
|
221
|
+
backing.set(key, structuredClone(value));
|
|
222
|
+
},
|
|
223
|
+
get: (key) => backing.get(key),
|
|
224
|
+
delete: (key) => {
|
|
225
|
+
backing.delete(key);
|
|
226
|
+
},
|
|
227
|
+
list: (prefix) =>
|
|
228
|
+
[...backing.entries()]
|
|
229
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
230
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
231
|
+
.map(([key, value]) => ({ key, value })),
|
|
232
|
+
}),
|
|
233
|
+
agentName: AGENT,
|
|
234
|
+
projectRoot: "/tmp",
|
|
235
|
+
logger: () => {},
|
|
236
|
+
});
|
|
237
|
+
const store = new ArtifactsStore({
|
|
238
|
+
stateRoot: await tempStateRoot(),
|
|
239
|
+
storage: coordinator,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const record = await store.tag({ key: "pr-1", data: { url: "u" } });
|
|
243
|
+
expect(backing.get(storageKeys.artifact(AGENT, record.id))).toMatchObject({
|
|
244
|
+
id: record.id,
|
|
245
|
+
key: "pr-1",
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await tick();
|
|
249
|
+
await store.tag({ key: "pr-1", data: { url: "u2" } });
|
|
250
|
+
expect([...backing.keys()]).toHaveLength(1);
|
|
251
|
+
expect((await store.list())[0]?.data).toEqual({ url: "u2" });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("falls back to filesystem rows when the sink cannot delete", async () => {
|
|
255
|
+
const backing = new Map<string, JsonValue>();
|
|
256
|
+
// No `delete`: the sink could never evict past the cap, so artifact
|
|
257
|
+
// rows must not land there.
|
|
258
|
+
const coordinator = new StorageCoordinator({
|
|
259
|
+
definition: defineStorage({
|
|
260
|
+
put: (key, value) => {
|
|
261
|
+
backing.set(key, structuredClone(value));
|
|
262
|
+
},
|
|
263
|
+
get: (key) => backing.get(key),
|
|
264
|
+
list: (prefix) =>
|
|
265
|
+
[...backing.entries()]
|
|
266
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
267
|
+
.map(([key, value]) => ({ key, value })),
|
|
268
|
+
}),
|
|
269
|
+
agentName: AGENT,
|
|
270
|
+
projectRoot: "/tmp",
|
|
271
|
+
logger: () => {},
|
|
272
|
+
});
|
|
273
|
+
expect(coordinator.supportsArtifacts).toBe(false);
|
|
274
|
+
|
|
275
|
+
const store = new ArtifactsStore({
|
|
276
|
+
stateRoot: await tempStateRoot(),
|
|
277
|
+
storage: coordinator,
|
|
278
|
+
});
|
|
279
|
+
await store.tag({ key: "local", data: 1 });
|
|
280
|
+
expect(backing.size).toBe(0);
|
|
281
|
+
expect(await store.list()).toHaveLength(1);
|
|
282
|
+
});
|
|
283
|
+
});
|