feat: dispatch authenticated project issue requests

This commit is contained in:
-Puter
2026-07-24 02:00:02 +05:30
parent 7974bd5f3e
commit 38c73847e0
11 changed files with 806 additions and 65 deletions

View File

@@ -7,6 +7,7 @@
".": "./src/index.ts",
"./agent-os": "./src/agent-os.ts",
"./project": "./src/project.ts",
"./project-issue": "./src/project-issue.ts",
"./signal": "./src/signal.ts"
},
"scripts": {

View File

@@ -1,4 +1,5 @@
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
export * from "./agent-os";
export * from "./project";
export * from "./project-issue";
export * from "./signal";

View File

@@ -0,0 +1,77 @@
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import {
decodeProjectIssueRequest,
ProjectIssueTransitionError,
ProjectIssueValidationError,
projectIssueDraftFromSignal,
queueProjectIssue,
validateProjectIssueDraft,
} from "./project-issue";
describe("project issue primitives", () => {
it("normalizes an explicit project request", () => {
const request = Effect.runSync(
decodeProjectIssueRequest({
body: " Investigate the staging failure. ",
kind: "explicit",
projectId: "project-1",
title: " Staging failure ",
})
);
expect(request).toEqual({
body: "Investigate the staging failure.",
kind: "explicit",
projectId: "project-1",
title: "Staging failure",
});
});
it("rejects invalid issue drafts before persistence", () => {
const error = Effect.runSync(
Effect.flip(
validateProjectIssueDraft({
body: "too short",
title: "No",
})
)
);
expect(error).toBeInstanceOf(ProjectIssueValidationError);
expect(error.reason).toBe("TitleTooShort");
});
it("projects a project Signal into the existing issue shape", () => {
const draft = Effect.runSync(
projectIssueDraftFromSignal({
problemStatement: {
constraints: ["Do not change production"],
desiredOutcome: "Staging deploys successfully",
summary: "The deploy fails during DNS resolution",
title: "Fix staging deploy DNS failure",
},
signalId: "signal-1",
})
);
expect(draft.title).toBe("Fix staging deploy DNS failure");
expect(draft.body).toContain("The deploy fails during DNS resolution");
expect(draft.body).toContain("Source Signal: signal-1");
});
it("queues retryable issue states and preserves active states", () => {
expect(Effect.runSync(queueProjectIssue("open"))).toBe("queued");
expect(Effect.runSync(queueProjectIssue("failed"))).toBe("queued");
expect(Effect.runSync(queueProjectIssue("needs-input"))).toBe("queued");
expect(Effect.runSync(queueProjectIssue("working"))).toBe("working");
});
it("does not reopen completed issues", () => {
const error = Effect.runSync(Effect.flip(queueProjectIssue("completed")));
expect(error).toBeInstanceOf(ProjectIssueTransitionError);
expect(error.reason).toBe("CompletedIssue");
});
});

View File

@@ -0,0 +1,265 @@
/* eslint-disable max-classes-per-file -- each domain failure has a distinct tagged reason. */
import { Effect, Schema } from "effect";
import { ProblemStatement, ProjectId, SignalId } from "./signal.js";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
export const ProjectIssueId = MeaningfulString.pipe(
Schema.brand("ProjectIssueId")
);
export type ProjectIssueId = typeof ProjectIssueId.Type;
export const ProjectIssueStatus = Schema.Literals([
"open",
"queued",
"working",
"needs-input",
"completed",
"failed",
]);
export type ProjectIssueStatus = typeof ProjectIssueStatus.Type;
const ProjectIssueDraftInput = Schema.Struct({
body: Schema.String,
title: Schema.String,
});
export const ProjectIssueDraft = Schema.Struct({
body: MeaningfulString,
title: MeaningfulString,
});
export type ProjectIssueDraft = typeof ProjectIssueDraft.Type;
export const ExplicitProjectIssueRequest = Schema.Struct({
body: Schema.String,
kind: Schema.Literal("explicit"),
projectId: ProjectId,
title: Schema.String,
});
export const SignalProjectIssueRequest = Schema.Struct({
kind: Schema.Literal("signal"),
signalId: SignalId,
});
export const ProjectIssueRequest = Schema.Union([
ExplicitProjectIssueRequest,
SignalProjectIssueRequest,
]);
export type ProjectIssueRequest = typeof ProjectIssueRequest.Type;
export const ProjectIssueDispatchInput = Schema.Struct({
issueId: ProjectIssueId,
kind: Schema.Literal("project.issue.started"),
projectId: ProjectId,
});
export type ProjectIssueDispatchInput = typeof ProjectIssueDispatchInput.Type;
export const ProjectIssueRequestResult = Schema.Struct({
acceptedAt: MeaningfulString,
dispatchId: MeaningfulString,
issueId: ProjectIssueId,
projectId: ProjectId,
status: Schema.Literals(["queued", "working"]),
});
export type ProjectIssueRequestResult = typeof ProjectIssueRequestResult.Type;
export const ProjectIssueValidationReason = Schema.Literals([
"InvalidInput",
"TitleTooShort",
"TitleTooLong",
"BodyTooShort",
"BodyTooLong",
]);
export type ProjectIssueValidationReason =
typeof ProjectIssueValidationReason.Type;
export class ProjectIssueValidationError extends Schema.TaggedErrorClass<ProjectIssueValidationError>()(
"ProjectIssueValidationError",
{
message: Schema.String,
reason: ProjectIssueValidationReason,
}
) {}
export const ProjectIssueRequestErrorReason = Schema.Literals([
"InvalidInput",
"InvalidProjectId",
"InvalidSignalId",
]);
export type ProjectIssueRequestErrorReason =
typeof ProjectIssueRequestErrorReason.Type;
export class ProjectIssueRequestError extends Schema.TaggedErrorClass<ProjectIssueRequestError>()(
"ProjectIssueRequestError",
{
message: Schema.String,
reason: ProjectIssueRequestErrorReason,
}
) {}
export const ProjectIssueTransitionReason = Schema.Literals([
"InvalidStatus",
"CompletedIssue",
]);
export type ProjectIssueTransitionReason =
typeof ProjectIssueTransitionReason.Type;
export class ProjectIssueTransitionError extends Schema.TaggedErrorClass<ProjectIssueTransitionError>()(
"ProjectIssueTransitionError",
{
message: Schema.String,
reason: ProjectIssueTransitionReason,
}
) {}
export const validateProjectIssueDraft = (
input: unknown
): Effect.Effect<ProjectIssueDraft, ProjectIssueValidationError> =>
Effect.gen(function* validateDraft() {
const decoded = yield* Schema.decodeUnknownEffect(ProjectIssueDraftInput)(
input
).pipe(
Effect.mapError(
() =>
new ProjectIssueValidationError({
message: "Issue title and body are required",
reason: "InvalidInput",
})
)
);
const title = decoded.title.trim();
const body = decoded.body.trim();
if (title.length < 3) {
return yield* Effect.fail(
new ProjectIssueValidationError({
message: "Issue title must be between 3 and 160 characters",
reason: "TitleTooShort",
})
);
}
if (title.length > 160) {
return yield* Effect.fail(
new ProjectIssueValidationError({
message: "Issue title must be between 3 and 160 characters",
reason: "TitleTooLong",
})
);
}
if (body.length < 10) {
return yield* Effect.fail(
new ProjectIssueValidationError({
message: "Issue description must be between 10 and 10000 characters",
reason: "BodyTooShort",
})
);
}
if (body.length > 10_000) {
return yield* Effect.fail(
new ProjectIssueValidationError({
message: "Issue description must be between 10 and 10000 characters",
reason: "BodyTooLong",
})
);
}
return { body, title };
});
export const decodeProjectIssueRequest = (
input: unknown
): Effect.Effect<
ProjectIssueRequest,
ProjectIssueRequestError | ProjectIssueValidationError
> =>
Effect.gen(function* decodeRequest() {
const request = yield* Schema.decodeUnknownEffect(ProjectIssueRequest)(
input
).pipe(
Effect.mapError(
() =>
new ProjectIssueRequestError({
message:
"Use a signal request or an explicit request with projectId, title, and body",
reason: "InvalidInput",
})
)
);
if (request.kind === "explicit") {
const draft = yield* validateProjectIssueDraft(request);
return { ...request, ...draft };
}
return request;
});
export const projectIssueDraftFromSignal = (input: unknown) =>
Effect.gen(function* draftFromSignal() {
const source = yield* Schema.decodeUnknownEffect(
Schema.Struct({
problemStatement: ProblemStatement,
signalId: SignalId,
})
)(input).pipe(
Effect.mapError(
() =>
new ProjectIssueRequestError({
message: "Project Signal is missing a valid problem statement",
reason: "InvalidSignalId",
})
)
);
const constraints =
source.problemStatement.constraints.length === 0
? "None"
: source.problemStatement.constraints
.map((constraint) => `- ${constraint}`)
.join("\n");
return yield* validateProjectIssueDraft({
body: [
source.problemStatement.summary,
`Desired outcome: ${source.problemStatement.desiredOutcome}`,
`Constraints:\n${constraints}`,
`Source Signal: ${source.signalId}`,
].join("\n\n"),
title: source.problemStatement.title,
});
});
export const queueProjectIssue = (
input: unknown
): Effect.Effect<"queued" | "working", ProjectIssueTransitionError> =>
Effect.gen(function* queueIssue() {
const status = yield* Schema.decodeUnknownEffect(ProjectIssueStatus)(
input
).pipe(
Effect.mapError(
() =>
new ProjectIssueTransitionError({
message: "Issue has an invalid status",
reason: "InvalidStatus",
})
)
);
if (status === "queued" || status === "working") {
return status;
}
if (status === "completed") {
return yield* Effect.fail(
new ProjectIssueTransitionError({
message: "Completed issues cannot be queued again",
reason: "CompletedIssue",
})
);
}
return "queued" as const;
});