feat: add tenant-scoped signals backend #2
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./agent-os": "./src/agent-os.ts"
|
||||
"./agent-os": "./src/agent-os.ts",
|
||||
"./signal": "./src/signal.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit",
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
||||
export * from "./agent-os";
|
||||
export * from "./signal";
|
||||
|
||||
273
packages/primitives/src/signal.test.ts
Normal file
273
packages/primitives/src/signal.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { composeSignal, SignalValidationError } from "./signal";
|
||||
import type { SignalValidationReason } from "./signal";
|
||||
|
||||
type TestSignalScope =
|
||||
| {
|
||||
_tag: "Organization";
|
||||
organizationId: string;
|
||||
}
|
||||
| {
|
||||
_tag: "Project";
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
interface TestSourceMessage {
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
rawText: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface TestSignalInput {
|
||||
id: string;
|
||||
scope: TestSignalScope;
|
||||
sourceMessages: [TestSourceMessage, TestSourceMessage];
|
||||
problemStatement: {
|
||||
title: string;
|
||||
summary: string;
|
||||
desiredOutcome: string;
|
||||
constraints: string[];
|
||||
};
|
||||
processedBy: {
|
||||
agentName: string;
|
||||
agentInstanceId: string;
|
||||
};
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const makeSignalInput = (): TestSignalInput => ({
|
||||
createdAt: 300,
|
||||
id: "signal-1",
|
||||
problemStatement: {
|
||||
constraints: ["Keep existing account data"],
|
||||
desiredOutcome: "Users can complete setup.",
|
||||
summary: "The setup flow stops before completion.",
|
||||
title: "Users cannot finish setup",
|
||||
},
|
||||
processedBy: {
|
||||
agentInstanceId: "organization-1",
|
||||
agentName: "zopu",
|
||||
},
|
||||
scope: {
|
||||
_tag: "Organization",
|
||||
organizationId: "organization-1",
|
||||
},
|
||||
sourceMessages: [
|
||||
{
|
||||
conversationId: "conversation-1",
|
||||
createdAt: 100,
|
||||
messageId: "message-1",
|
||||
rawText: "First source",
|
||||
},
|
||||
{
|
||||
conversationId: "conversation-1",
|
||||
createdAt: 200,
|
||||
messageId: "message-2",
|
||||
rawText: "Second source",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const runSignal = (input: unknown) => Effect.runSync(composeSignal(input));
|
||||
|
||||
const getValidationError = (
|
||||
input: unknown,
|
||||
reason: SignalValidationReason
|
||||
): SignalValidationError => {
|
||||
const error = Effect.runSync(Effect.flip(composeSignal(input)));
|
||||
expect(error).toBeInstanceOf(SignalValidationError);
|
||||
expect(error.reason).toBe(reason);
|
||||
return error;
|
||||
};
|
||||
|
||||
describe("composeSignal", () => {
|
||||
it("accepts organization scope", () => {
|
||||
const signal = runSignal(makeSignalInput());
|
||||
|
||||
expect(signal.scope).toEqual({
|
||||
_tag: "Organization",
|
||||
organizationId: "organization-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts project scope", () => {
|
||||
const input = makeSignalInput();
|
||||
input.scope = {
|
||||
_tag: "Project",
|
||||
organizationId: "organization-1",
|
||||
projectId: "project-1",
|
||||
};
|
||||
|
||||
const signal = runSignal(input);
|
||||
|
||||
expect(signal.scope).toEqual({
|
||||
_tag: "Project",
|
||||
organizationId: "organization-1",
|
||||
projectId: "project-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty IDs and processed-agent identity", () => {
|
||||
const mutations: readonly ((input: TestSignalInput) => void)[] = [
|
||||
(input) => {
|
||||
input.id = "";
|
||||
},
|
||||
(input) => {
|
||||
input.scope.organizationId = "";
|
||||
},
|
||||
(input) => {
|
||||
input.scope = {
|
||||
_tag: "Project",
|
||||
organizationId: "organization-1",
|
||||
projectId: "",
|
||||
};
|
||||
},
|
||||
(input) => {
|
||||
input.sourceMessages[0].conversationId = "";
|
||||
},
|
||||
(input) => {
|
||||
input.sourceMessages[0].messageId = "";
|
||||
},
|
||||
(input) => {
|
||||
input.processedBy.agentName = "";
|
||||
},
|
||||
(input) => {
|
||||
input.processedBy.agentInstanceId = "";
|
||||
},
|
||||
];
|
||||
|
||||
for (const mutate of mutations) {
|
||||
const input = makeSignalInput();
|
||||
mutate(input);
|
||||
getValidationError(input, "InvalidInput");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects negative and fractional timestamps", () => {
|
||||
const mutations: readonly ((input: TestSignalInput) => void)[] = [
|
||||
(input) => {
|
||||
input.sourceMessages[0].createdAt = -1;
|
||||
},
|
||||
(input) => {
|
||||
input.sourceMessages[0].createdAt = 1.5;
|
||||
},
|
||||
(input) => {
|
||||
input.createdAt = -1;
|
||||
},
|
||||
(input) => {
|
||||
input.createdAt = 1.5;
|
||||
},
|
||||
];
|
||||
|
||||
for (const mutate of mutations) {
|
||||
const input = makeSignalInput();
|
||||
mutate(input);
|
||||
getValidationError(input, "InvalidInput");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty problem-statement fields", () => {
|
||||
for (const field of ["title", "summary", "desiredOutcome"] as const) {
|
||||
const input = makeSignalInput();
|
||||
input.problemStatement[field] = "";
|
||||
getValidationError(input, "InvalidInput");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects whitespace-only problem fields and constraints", () => {
|
||||
const input = makeSignalInput();
|
||||
input.problemStatement.title = " ";
|
||||
input.problemStatement.constraints = ["\t\n"];
|
||||
|
||||
getValidationError(input, "InvalidInput");
|
||||
});
|
||||
|
||||
it("rejects an empty constraint item", () => {
|
||||
const input = makeSignalInput();
|
||||
input.problemStatement.constraints = [""];
|
||||
|
||||
getValidationError(input, "InvalidInput");
|
||||
});
|
||||
|
||||
it("accepts an empty constraints list", () => {
|
||||
const input = makeSignalInput();
|
||||
input.problemStatement.constraints = [];
|
||||
|
||||
expect(runSignal(input).problemStatement.constraints).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an empty source list", () => {
|
||||
const input = {
|
||||
...makeSignalInput(),
|
||||
sourceMessages: [],
|
||||
};
|
||||
|
||||
getValidationError(input, "InvalidInput");
|
||||
});
|
||||
|
||||
it("rejects duplicate source identities", () => {
|
||||
const input = makeSignalInput();
|
||||
input.sourceMessages[1].messageId = input.sourceMessages[0].messageId;
|
||||
|
||||
getValidationError(input, "DuplicateSource");
|
||||
});
|
||||
|
||||
it("rejects sources from mixed conversations", () => {
|
||||
const input = makeSignalInput();
|
||||
input.sourceMessages[1].conversationId = "conversation-2";
|
||||
|
||||
getValidationError(input, "MixedConversation");
|
||||
});
|
||||
|
||||
it("rejects decreasing source timestamps", () => {
|
||||
const input = makeSignalInput();
|
||||
input.sourceMessages[1].createdAt = 99;
|
||||
|
||||
getValidationError(input, "OutOfOrderSource");
|
||||
});
|
||||
|
||||
it("preserves source order and exact raw whitespace", () => {
|
||||
const input = makeSignalInput();
|
||||
input.sourceMessages[0].rawText = " first source\n";
|
||||
input.sourceMessages[1].rawText = "\tsecond source ";
|
||||
|
||||
const signal = runSignal(input);
|
||||
|
||||
expect(signal.sourceMessages.map(({ messageId }) => messageId)).toEqual([
|
||||
"message-1",
|
||||
"message-2",
|
||||
]);
|
||||
expect(signal.sourceMessages.map(({ rawText }) => rawText)).toEqual([
|
||||
" first source\n",
|
||||
"\tsecond source ",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves problem-statement whitespace while validating trimmed non-emptiness", () => {
|
||||
const input = makeSignalInput();
|
||||
input.problemStatement.title = " Setup blocked ";
|
||||
input.problemStatement.constraints = [" Preserve data "];
|
||||
|
||||
const signal = runSignal(input);
|
||||
|
||||
expect(signal.problemStatement.title).toBe(" Setup blocked ");
|
||||
expect(signal.problemStatement.constraints).toEqual([" Preserve data "]);
|
||||
});
|
||||
|
||||
it("does not include candidate, work-unit, or run fields", () => {
|
||||
const signal = runSignal({
|
||||
...makeSignalInput(),
|
||||
candidateId: "candidate-1",
|
||||
runId: "run-1",
|
||||
workUnitId: "work-unit-1",
|
||||
});
|
||||
|
||||
expect(signal).not.toHaveProperty("candidateId");
|
||||
expect(signal).not.toHaveProperty("workUnitId");
|
||||
expect(signal).not.toHaveProperty("runId");
|
||||
});
|
||||
});
|
||||
159
packages/primitives/src/signal.ts
Normal file
159
packages/primitives/src/signal.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
const MeaningfulString = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
})
|
||||
);
|
||||
|
||||
export const SignalId = MeaningfulString.pipe(Schema.brand("SignalId"));
|
||||
export type SignalId = typeof SignalId.Type;
|
||||
|
||||
export const OrganizationId = MeaningfulString.pipe(
|
||||
Schema.brand("OrganizationId")
|
||||
);
|
||||
export type OrganizationId = typeof OrganizationId.Type;
|
||||
|
||||
export const ProjectId = MeaningfulString.pipe(Schema.brand("ProjectId"));
|
||||
export type ProjectId = typeof ProjectId.Type;
|
||||
|
||||
export const ConversationId = MeaningfulString.pipe(
|
||||
Schema.brand("ConversationId")
|
||||
);
|
||||
export type ConversationId = typeof ConversationId.Type;
|
||||
|
||||
export const ConversationMessageId = MeaningfulString.pipe(
|
||||
Schema.brand("ConversationMessageId")
|
||||
);
|
||||
export type ConversationMessageId = typeof ConversationMessageId.Type;
|
||||
|
||||
export const TimestampMs = Schema.Int.check(
|
||||
Schema.isGreaterThanOrEqualTo(0)
|
||||
).pipe(Schema.brand("TimestampMs"));
|
||||
export type TimestampMs = typeof TimestampMs.Type;
|
||||
|
||||
export const OrganizationSignalScope = Schema.Struct({
|
||||
_tag: Schema.Literal("Organization"),
|
||||
organizationId: OrganizationId,
|
||||
});
|
||||
export type OrganizationSignalScope = typeof OrganizationSignalScope.Type;
|
||||
|
||||
export const ProjectSignalScope = Schema.Struct({
|
||||
_tag: Schema.Literal("Project"),
|
||||
organizationId: OrganizationId,
|
||||
projectId: ProjectId,
|
||||
});
|
||||
export type ProjectSignalScope = typeof ProjectSignalScope.Type;
|
||||
|
||||
export const SignalScope = Schema.Union([
|
||||
OrganizationSignalScope,
|
||||
ProjectSignalScope,
|
||||
]);
|
||||
export type SignalScope = typeof SignalScope.Type;
|
||||
|
||||
export const SignalSourceMessage = Schema.Struct({
|
||||
conversationId: ConversationId,
|
||||
createdAt: TimestampMs,
|
||||
messageId: ConversationMessageId,
|
||||
rawText: Schema.String,
|
||||
});
|
||||
export type SignalSourceMessage = typeof SignalSourceMessage.Type;
|
||||
|
||||
export const ProblemStatement = Schema.Struct({
|
||||
constraints: Schema.Array(MeaningfulString),
|
||||
desiredOutcome: MeaningfulString,
|
||||
summary: MeaningfulString,
|
||||
title: MeaningfulString,
|
||||
});
|
||||
export type ProblemStatement = typeof ProblemStatement.Type;
|
||||
|
||||
export const ProcessedByAgent = Schema.Struct({
|
||||
agentInstanceId: MeaningfulString,
|
||||
agentName: MeaningfulString,
|
||||
});
|
||||
export type ProcessedByAgent = typeof ProcessedByAgent.Type;
|
||||
|
||||
const signalFields = {
|
||||
createdAt: TimestampMs,
|
||||
id: SignalId,
|
||||
problemStatement: ProblemStatement,
|
||||
processedBy: ProcessedByAgent,
|
||||
scope: SignalScope,
|
||||
sourceMessages: Schema.NonEmptyArray(SignalSourceMessage),
|
||||
} as const;
|
||||
|
||||
export const SignalInput = Schema.Struct(signalFields);
|
||||
export type SignalInput = typeof SignalInput.Type;
|
||||
|
||||
export const Signal = Schema.Struct(signalFields);
|
||||
export type Signal = typeof Signal.Type;
|
||||
|
||||
export const SignalValidationReason = Schema.Literals([
|
||||
"InvalidInput",
|
||||
"DuplicateSource",
|
||||
"MixedConversation",
|
||||
"OutOfOrderSource",
|
||||
]);
|
||||
export type SignalValidationReason = typeof SignalValidationReason.Type;
|
||||
|
||||
export class SignalValidationError extends Schema.TaggedErrorClass<SignalValidationError>()(
|
||||
"SignalValidationError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: SignalValidationReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
export const composeSignal = Effect.fn("Signal.compose")(
|
||||
function* composeSignal(input: unknown) {
|
||||
const signal: Signal = yield* Schema.decodeUnknownEffect(SignalInput)(
|
||||
input
|
||||
).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new SignalValidationError({
|
||||
message: cause.message,
|
||||
reason: "InvalidInput",
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const [{ conversationId, createdAt: firstCreatedAt }] =
|
||||
signal.sourceMessages;
|
||||
let previousCreatedAt = firstCreatedAt;
|
||||
const messageIds = new Set<ConversationMessageId>();
|
||||
|
||||
for (const source of signal.sourceMessages) {
|
||||
if (source.conversationId !== conversationId) {
|
||||
return yield* Effect.fail(
|
||||
new SignalValidationError({
|
||||
message: "Signal sources must belong to one conversation",
|
||||
reason: "MixedConversation",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (messageIds.has(source.messageId)) {
|
||||
return yield* Effect.fail(
|
||||
new SignalValidationError({
|
||||
message: "Signal sources must have unique message identities",
|
||||
reason: "DuplicateSource",
|
||||
})
|
||||
);
|
||||
}
|
||||
messageIds.add(source.messageId);
|
||||
|
||||
if (source.createdAt < previousCreatedAt) {
|
||||
return yield* Effect.fail(
|
||||
new SignalValidationError({
|
||||
message: "Signal source timestamps must be non-decreasing",
|
||||
reason: "OutOfOrderSource",
|
||||
})
|
||||
);
|
||||
}
|
||||
previousCreatedAt = source.createdAt;
|
||||
}
|
||||
|
||||
return signal;
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user