Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions packages/plugins/execution-history/src/sdk/collections.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "@effect/vitest";
import { Schema } from "effect";

import { RunRow } from "./collections";

// ---------------------------------------------------------------------------
// Schema-evolution tolerance for the JSON-document `runs` collection.
//
// `#20` added actorId/actorLabel/actorKind. Runs stored BEFORE that have no such
// key at all. The store passes the raw stored doc straight to the HTTP response
// encoder, so a legacy doc must both decode AND encode through `RunRow` without a
// "Missing key" error — otherwise the entire runs list 400s on the newest page.
// These fields use an optional key + a decoding default of null precisely so
// that holds.
// ---------------------------------------------------------------------------

// Hoisted: `Schema.*Sync` compiles a function, so it must not be rebuilt per call.
const decodeRunRow = Schema.decodeUnknownSync(RunRow);
const encodeRunRow = Schema.encodeUnknownSync(RunRow);

// A run document as written before the actor fields existed: the three actor
// keys are physically absent.
const legacyRunDoc = {
executionId: "exec_legacy",
status: "completed",
code: "noop",
resultJson: null,
errorText: null,
logsJson: null,
triggerKind: null,
triggerMetaJson: null,
startedAt: 1000,
completedAt: 2000,
durationMs: 1000,
toolCallCount: 0,
hadInteraction: false,
};

describe("RunRow legacy-document tolerance", () => {
it("decodes a legacy doc, defaulting the absent actor fields to null", () => {
const decoded = decodeRunRow(legacyRunDoc);
expect(decoded.actorId).toBeNull();
expect(decoded.actorLabel).toBeNull();
expect(decoded.actorKind).toBeNull();
// The decoded type is `string | null` (always present) — readers treat it as
// required; it just defaults to null for pre-actor runs.
expect(decoded.executionId).toBe("exec_legacy");
});

it("encodes a legacy doc with the actor keys absent (the runs-response regression)", () => {
// The store hands the raw stored doc to the response encoder, so the encode
// input is the legacy shape itself. With `NullOr` this threw
// "Missing key at [actorId]"; with the optional key it must succeed, and the
// absent actor keys stay absent (the encoder must not fabricate them).
const encoded = encodeRunRow(legacyRunDoc);
expect(encoded.executionId).toBe("exec_legacy");
expect(encoded.actorId ?? null).toBeNull();
expect(encoded.actorLabel ?? null).toBeNull();
expect(encoded.actorKind ?? null).toBeNull();
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

it("round-trips a run that DOES carry an actor through decode AND encode", () => {
const withActor = {
...legacyRunDoc,
actorId: "tok.access",
actorLabel: "phoenix",
actorKind: "service-token",
};
const decoded = decodeRunRow(withActor);
expect(decoded.actorId).toBe("tok.access");
expect(decoded.actorLabel).toBe("phoenix");
expect(decoded.actorKind).toBe("service-token");
// Encode must carry the present keys through unchanged (the path the runs
// response actually takes for an attributed run).
const encoded = encodeRunRow(decoded);
expect(encoded.actorId).toBe("tok.access");
expect(encoded.actorLabel).toBe("phoenix");
expect(encoded.actorKind).toBe("service-token");
});
});
26 changes: 20 additions & 6 deletions packages/plugins/execution-history/src/sdk/collections.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Schema } from "effect";
import { Effect, Schema } from "effect";

import { definePluginStorageCollection } from "@executor-js/sdk/core";

Expand Down Expand Up @@ -50,11 +50,25 @@ export const RunRow = Schema.Struct({
// Who/what the run acted as (from the trigger's `ExecutionActor`). `actorId`
// is the STABLE filter/facet key (a token client id, a user subject);
// `actorLabel` is the display snapshot at run time (machine name, email);
// `actorKind` is the credential class ("user", "service-token"). Null on runs
// recorded before a trigger actor was supplied.
actorId: Schema.NullOr(Schema.String),
actorLabel: Schema.NullOr(Schema.String),
actorKind: Schema.NullOr(Schema.String),
// `actorKind` is the credential class ("user", "service-token").
//
// Optional-key + decoding default, NOT `NullOr`: runs are stored as JSON
// documents, and rows written BEFORE these fields existed have no such key at
// all. `NullOr` requires the key to be present, so a legacy doc fails the
// response encoder ("Missing key at runs[0].actorId"). Making the key optional
// (tolerant on the wire) while defaulting an absent key to null on decode lets
// older docs decode/encode unchanged — and immunizes the collection against
// the next field added the same way. The decoded type stays `string | null`
// (always present), so every reader treats it as required.
actorId: Schema.optional(Schema.NullOr(Schema.String)).pipe(
Schema.withDecodingDefaultType(Effect.succeed(null)),
),
actorLabel: Schema.optional(Schema.NullOr(Schema.String)).pipe(
Schema.withDecodingDefaultType(Effect.succeed(null)),
),
actorKind: Schema.optional(Schema.NullOr(Schema.String)).pipe(
Schema.withDecodingDefaultType(Effect.succeed(null)),
),
startedAt: Schema.Number,
completedAt: Schema.NullOr(Schema.Number),
durationMs: Schema.NullOr(Schema.Number),
Expand Down
Loading