feat(primitives): add signal module
This commit is contained in:
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./agent-os": "./src/agent-os.ts"
|
"./agent-os": "./src/agent-os.ts",
|
||||||
|
"./signal": "./src/signal.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check-types": "tsc --noEmit",
|
"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 "./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