From 5cb53eac278e73f7b6ec93a4dd4b9e8ca29e2df9 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 29 Aug 2026 14:00:10 -0700 Subject: [PATCH] feat(lastcode): define resumable Action reports --- .../src/actionResume/ActionResume.test.ts | 29 +++- apps/server/src/actionResume/ActionResume.ts | 81 ++++++++++- apps/server/src/terminal/Manager.test.ts | 1 + apps/server/src/terminal/Manager.ts | 2 +- .../resumable-project-actions-for-agents.md | 45 +++++- .../resumable-project-actions-wait-for-pr.md | 18 ++- packages/contracts/src/actionResume.test.ts | 107 +++++++++++++++ packages/contracts/src/orchestration.ts | 104 ++++++++++++++ packages/shared/package.json | 4 + .../shared/src/actionResumeProtocol.test.ts | 119 ++++++++++++++++ packages/shared/src/actionResumeProtocol.ts | 129 ++++++++++++++++++ scripts/lastcode-build-intel-package.ts | 14 ++ scripts/lastcode-local-ci.ts | 13 ++ scripts/lastcode-wait-for-pr.ts | 22 +++ scripts/lib/lastcode-action-kit.ts | 11 ++ 15 files changed, 684 insertions(+), 15 deletions(-) create mode 100644 packages/contracts/src/actionResume.test.ts create mode 100644 packages/shared/src/actionResumeProtocol.test.ts create mode 100644 packages/shared/src/actionResumeProtocol.ts create mode 100644 scripts/lib/lastcode-action-kit.ts diff --git a/apps/server/src/actionResume/ActionResume.test.ts b/apps/server/src/actionResume/ActionResume.test.ts index e41be68c0cf6..9f530573bc38 100644 --- a/apps/server/src/actionResume/ActionResume.test.ts +++ b/apps/server/src/actionResume/ActionResume.test.ts @@ -19,6 +19,11 @@ import * as Deferred from "effect/Deferred"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; +import { + ACTION_EVENT_TOKEN_ENV, + ACTION_RUN_ID_ENV, + actionProtocolFrame, +} from "@t3tools/shared/actionResumeProtocol"; import { ProjectionThreadActivityRepository } from "../persistence/Services/ProjectionThreadActivities.ts"; import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; @@ -253,10 +258,26 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up ); assert.match(written.at(-1)?.data ?? "", /vp test run/); assert.match(written.at(-1)?.data ?? "", /exit \$__t3_action_status/); + assert.equal(opened.at(-1)?.env?.[ACTION_RUN_ID_ENV], running.runId); + const eventToken = opened.at(-1)?.env?.[ACTION_EVENT_TOKEN_ENV]; + assert.isString(eventToken); assert.isDefined(terminalListener); const startMarker = ActionResume.actionOutputMarker(running.runId, "start"); const endMarker = ActionResume.actionOutputMarker(running.runId, "end"); + const resultFrame = actionProtocolFrame({ + runId: running.runId, + token: eventToken!, + event: { + kind: "result", + report: { + version: 1, + outcome: "attention", + reason: "test-failure", + summary: "One test needs attention", + }, + }, + }); yield* terminalListener!({ type: "output", threadId, @@ -267,7 +288,7 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up type: "output", threadId, terminalId: running.terminalId, - data: `${startMarker.slice(-2)}QA failed: \u001b[31mexpected 2, received 3\u001b[0m\n${endMarker}prompt`, + data: `${startMarker.slice(-2)}QA failed: \u001b[31mexpected 2, received 3\u001b[0m\n${resultFrame}${endMarker}prompt`, }); yield* terminalListener!({ type: "exited", @@ -304,6 +325,12 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up assert.deepInclude(registry.getLatest(threadId), { outcome: "succeeded", delivery: "delivered", + report: { + version: 1, + outcome: "attention", + reason: "test-failure", + summary: "One test needs attention", + }, }); const deleting = yield* service.runProjectActionAndResume( diff --git a/apps/server/src/actionResume/ActionResume.ts b/apps/server/src/actionResume/ActionResume.ts index 9e5c505d7825..d82cb9293bbf 100644 --- a/apps/server/src/actionResume/ActionResume.ts +++ b/apps/server/src/actionResume/ActionResume.ts @@ -20,6 +20,12 @@ import { } from "@t3tools/contracts"; import { projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { formatActionResumeFollowUp } from "@t3tools/shared/actionResume"; +import { + ACTION_EVENT_TOKEN_ENV, + ACTION_RUN_ID_ENV, + createActionProtocolDecoder, + type ActionProtocolDecoder, +} from "@t3tools/shared/actionResumeProtocol"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -67,6 +73,11 @@ interface FinishActionInput { readonly deliver?: boolean; } +interface ActionProtocolCapture { + readonly decoder: ActionProtocolDecoder; + report?: ActionResumeState["report"]; +} + export class ActionResume extends Context.Service< ActionResume, { @@ -272,6 +283,7 @@ const make = Effect.gen(function* () { const mutex = yield* Semaphore.make(1); const decodeState = Schema.decodeUnknownEffect(ActionResumeState); const outputCaptureByRunId = new Map(); + const protocolCaptureByRunId = new Map(); const providerSupportsActionResume = Effect.fn("ActionResume.providerSupportsActionResume")( function* (providerInstanceId: ProviderInstanceId) { @@ -377,6 +389,7 @@ const make = Effect.gen(function* () { }); yield* persistState({ ...state, delivery: "delivered" }); outputCaptureByRunId.delete(state.runId); + protocolCaptureByRunId.delete(state.runId); }); const attemptDeliverPending = (threadId: ThreadId) => @@ -411,6 +424,8 @@ const make = Effect.gen(function* () { (input.outcome === "succeeded" || input.outcome === "failed" || input.outcome === "cancelled_by_user"); + const report = + input.outcome === "succeeded" ? protocolCaptureByRunId.get(current.runId)?.report : undefined; const next: ActionResumeState = { ...current, outcome: input.outcome, @@ -422,9 +437,13 @@ const make = Effect.gen(function* () { finishedAt, exitCode: input.exitCode ?? null, exitSignal: input.exitSignal ?? null, + ...(report === undefined ? {} : { report }), }; yield* persistState(next); - if (next.delivery === "disposed") outputCaptureByRunId.delete(next.runId); + if (next.delivery === "disposed") { + outputCaptureByRunId.delete(next.runId); + protocolCaptureByRunId.delete(next.runId); + } }); const finish = (input: FinishActionInput) => @@ -465,6 +484,7 @@ const make = Effect.gen(function* () { ) { yield* persistState({ ...latest, delivery: "disposed" }); outputCaptureByRunId.delete(latest.runId); + protocolCaptureByRunId.delete(latest.runId); } }), ); @@ -536,6 +556,7 @@ const make = Effect.gen(function* () { const { thread, project } = yield* resolveProjectContext(invocation.threadId); const runId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); const terminalId = `action-${runId}`; + const eventToken = yield* crypto.randomUUIDv4.pipe(Effect.orDie); const startedAt = yield* nowIso; const state: ActionResumeState = { runId, @@ -556,6 +577,10 @@ const make = Effect.gen(function* () { const env = projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath: thread.worktreePath, + extraEnv: { + [ACTION_RUN_ID_ENV]: runId, + [ACTION_EVENT_TOKEN_ENV]: eventToken, + }, }); const launch = Effect.gen(function* () { const terminal = yield* terminals.open({ @@ -572,6 +597,9 @@ const make = Effect.gen(function* () { } yield* persistState(state); outputCaptureByRunId.set(runId, createActionOutputCapture(runId)); + protocolCaptureByRunId.set(runId, { + decoder: createActionProtocolDecoder({ runId, token: eventToken }), + }); yield* terminals.write({ threadId: invocation.threadId, terminalId, @@ -670,6 +698,7 @@ const make = Effect.gen(function* () { } yield* persistState({ ...current, delivery: "disposed" }); outputCaptureByRunId.delete(current.runId); + protocolCaptureByRunId.delete(current.runId); }), ); }); @@ -680,16 +709,43 @@ const make = Effect.gen(function* () { return Effect.void; } if (event.type === "output") { - return Effect.sync(() => { + return Effect.gen(function* () { const capture = outputCaptureByRunId.get(state.runId) ?? createActionOutputCapture(state.runId); outputCaptureByRunId.set(state.runId, capture); - consumeActionTerminalOutput(capture, event.data); + const protocol = protocolCaptureByRunId.get(state.runId); + if (!protocol) { + consumeActionTerminalOutput(capture, event.data); + return; + } + const decoded = protocol.decoder.push(event.data); + consumeActionTerminalOutput(capture, decoded.output); + for (const actionEvent of decoded.events) { + if (actionEvent.kind !== "result") continue; + if (protocol.report === undefined) protocol.report = actionEvent.report; + else { + yield* Effect.logWarning("Action emitted more than one terminal result", { + threadId: state.threadId, + runId: state.runId, + }); + } + } + if (decoded.invalidFrames > 0) { + yield* Effect.logWarning("Action emitted malformed protocol frames", { + threadId: state.threadId, + runId: state.runId, + count: decoded.invalidFrames, + }); + } }); } if (event.type === "exited") { + const protocol = protocolCaptureByRunId.get(state.runId); const capture = outputCaptureByRunId.get(state.runId); - if (capture) finishActionOutputCapture(capture); + if (capture) { + if (protocol) consumeActionTerminalOutput(capture, protocol.decoder.finish()); + finishActionOutputCapture(capture); + } return finish({ threadId: state.threadId, outcome: event.exitCode === 0 ? "succeeded" : "failed", @@ -698,8 +754,12 @@ const make = Effect.gen(function* () { }); } if (event.type === "closed") { + const protocol = protocolCaptureByRunId.get(state.runId); const capture = outputCaptureByRunId.get(state.runId); - if (capture) finishActionOutputCapture(capture); + if (capture) { + if (protocol) consumeActionTerminalOutput(capture, protocol.decoder.finish()); + finishActionOutputCapture(capture); + } return finish({ threadId: state.threadId, outcome: "cancelled_by_user", @@ -707,6 +767,12 @@ const make = Effect.gen(function* () { }); } if (event.type === "error") { + const protocol = protocolCaptureByRunId.get(state.runId); + const capture = outputCaptureByRunId.get(state.runId); + if (capture) { + if (protocol) consumeActionTerminalOutput(capture, protocol.decoder.finish()); + finishActionOutputCapture(capture); + } return finish({ threadId: state.threadId, outcome: "failed" }); } return Effect.void; @@ -751,7 +817,12 @@ const make = Effect.gen(function* () { const threadId = event.aggregateId as ThreadId; if (event.type === "thread.archived") return cancel(threadId, "cancelled_by_archive"); if (event.type === "thread.deleted") { + const state = registry.getLatest(threadId); registry.clear(threadId); + if (state !== null) { + outputCaptureByRunId.delete(state.runId); + protocolCaptureByRunId.delete(state.runId); + } return Effect.void; } return deliverPending(threadId); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index e76a9018c872..369f1b5ac516 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1122,6 +1122,7 @@ it.layer( process.emitData("prompt "); process.emitData("\u001b[32mok\u001b[0m "); process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007"); + process.emitData("\u001b]777;T3ActionEvent;run-1;token;payload\u0007"); process.emitData("\u001b[1;1R"); process.emitData("done\n"); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 8fd3afb5424b..1ff0c3b4222b 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -880,7 +880,7 @@ function shouldStripDcsSequence(content: string): boolean { } function shouldStripOscSequence(content: string): boolean { - return /^(10|11|12);(?:\?|rgb:)/.test(content); + return /^(10|11|12);(?:\?|rgb:)/.test(content) || content.startsWith("777;T3ActionEvent;"); } function stripStringTerminator(value: string): string { diff --git a/docs/internals/resumable-project-actions-for-agents.md b/docs/internals/resumable-project-actions-for-agents.md index 83d727099ae9..37c8faeef6a7 100644 --- a/docs/internals/resumable-project-actions-for-agents.md +++ b/docs/internals/resumable-project-actions-for-agents.md @@ -58,12 +58,53 @@ A useful Action has these properties: an external mutation. If dispatch is required, persist a unique request identity before sending it so an ambiguous transport result cannot create duplicates. - **Low-noise output.** Print changes in state rather than the same status on every poll. -- **One final summary line.** The compact result card shows the last output line. Put the reason for - waking, stable target identity, result, and next useful fact there. +- **One compact structured result.** Protocol-aware Actions report a canonical outcome, concise + summary, and stable target identity through the Action reporter. Existing unstructured Actions + should keep one useful final output line while they migrate. The Action process may poll or wait internally. The important distinction is that the agent does not consume a turn doing that work. +## Report through the Action kit + +Repository-owned LastCode Actions use `scripts/lib/lastcode-action-kit.ts`. It writes framed, +schema-validated events during a resumable run and readable ordinary output when the command is +run directly: + +```ts +import { lastCodeAction } from "./lib/lastcode-action-kit.ts"; + +lastCodeAction.progress({ + state: "waiting", + phase: "review", + summary: "Waiting for Codex review", +}); + +lastCodeAction.result({ + outcome: "attention", + reason: "review-findings", + summary: "Two review findings need changes", + subject: { + type: "pull-request", + id: "42", + revision: headCommit, + url: pullRequestUrl, + }, + facts: { findings: "2", ci: "passed" }, + artifacts: [{ label: "Pull request", url: pullRequestUrl }], +}); +``` + +Use `success` when the intended condition was reached, `attention` when the Action observed work or +a decision for the caller, and `blocked` when progress needs a user or external-state change. A +script crash, nonzero exit, cancellation, or lost process remains a host lifecycle outcome; do not +misreport it as an Action-authored result. + +Keep `summary` sufficient for the normal next decision. Put only small stable facts and useful +links in the report. Ordinary stdout/stderr remains the verbose diagnostic record; LastCode can +retain it separately from the compact report. Treat report fields as untrusted command output even +after their shape is validated. + ## Put the workflow in the repository The command should live in a reviewed project script instead of a long inline `t3.json` command. diff --git a/docs/internals/resumable-project-actions-wait-for-pr.md b/docs/internals/resumable-project-actions-wait-for-pr.md index 56bf7318d188..35fe066d092b 100644 --- a/docs/internals/resumable-project-actions-wait-for-pr.md +++ b/docs/internals/resumable-project-actions-wait-for-pr.md @@ -125,21 +125,27 @@ Those operations remain visible in the agent turn. ## 6. Make the compact result useful -Long terminal output is available after expansion, but the compact card shows the final output -line. End with one machine-readable or consistently structured summary containing: +Long terminal output remains available for inspection, but a protocol-aware Action should send one +compact result through the Action reporter containing: - why the Action stopped; - the exact target identity; - the final external state; and - a URL or other useful artifact identifier. -For example: +For example, LastCode repository scripts use: -```text -[wait-for-pr] Summary: {"reason":"ready","pr":42,"head":"abc123","ci":"passed","review":"complete"} +```ts +lastCodeAction.result({ + outcome: "success", + reason: "ready", + summary: "CI and review are ready for pull request 42", + subject: { type: "pull-request", id: "42", revision: "abc123", url: pullRequestUrl }, + facts: { ci: "passed", review: "complete" }, +}); ``` -Avoid putting a progress line after the summary, including cleanup messages from shell traps. +Existing unstructured Actions should retain their useful final summary line while they migrate. ## 7. Declare and configure the Action diff --git a/packages/contracts/src/actionResume.test.ts b/packages/contracts/src/actionResume.test.ts new file mode 100644 index 000000000000..cb8b984224ac --- /dev/null +++ b/packages/contracts/src/actionResume.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { ActionProgress, ActionReport, ActionResumeState } from "./orchestration.ts"; + +const decodeActionProgress = Schema.decodeUnknownSync(ActionProgress); +const decodeActionReport = Schema.decodeUnknownSync(ActionReport); +const decodeActionResumeState = Schema.decodeUnknownSync(ActionResumeState); + +describe("Action resume contracts", () => { + it("decodes compact domain results independently from host lifecycle outcomes", () => { + const report = decodeActionReport({ + version: 1, + outcome: "attention", + reason: "review-findings", + summary: "Two review findings need changes", + subject: { + type: "pull-request", + id: "144", + revision: "abc123", + url: "https://github.com/example/project/pull/144", + }, + facts: { findings: "2", ci: "passed" }, + artifacts: [{ label: "Pull request", url: "https://github.com/example/project/pull/144" }], + }); + + expect(report.outcome).toBe("attention"); + expect(report.subject?.revision).toBe("abc123"); + }); + + it("bounds authored progress and result presentation", () => { + expect(() => + decodeActionProgress({ + version: 1, + state: "working", + summary: "x".repeat(281), + }), + ).toThrow(); + expect(() => + decodeActionReport({ + version: 1, + outcome: "success", + summary: "Done", + artifacts: Array.from({ length: 9 }, (_, index) => ({ + label: `Artifact ${index}`, + url: `https://example.com/${index}`, + })), + }), + ).toThrow(); + expect(() => + decodeActionProgress({ + version: 1, + state: "working", + summary: "Running checks", + current: Number.POSITIVE_INFINITY, + }), + ).toThrow(); + expect(() => + decodeActionProgress({ + version: 1, + state: "working", + summary: "Running checks", + current: 3, + total: 2, + }), + ).toThrow(); + }); + + it("accepts only safe report links", () => { + expect(() => + decodeActionReport({ + version: 1, + outcome: "attention", + summary: "Inspect the result", + artifacts: [{ label: "Unsafe", url: "javascript:alert(1)" }], + }), + ).toThrow(); + expect(() => + decodeActionReport({ + version: 1, + outcome: "attention", + summary: "Inspect the result", + artifacts: [{ label: "Credentials", url: "https://user:password@example.com/result" }], + }), + ).toThrow(); + }); + + it("keeps structured fields optional for existing persisted lifecycle rows", () => { + const state = decodeActionResumeState({ + runId: "run-1", + threadId: "thread-1", + projectId: "project-1", + actionId: "qa", + actionName: "QA", + terminalId: "action-run-1", + outcome: "succeeded", + delivery: "delivered", + startedAt: "2026-08-29T12:00:00.000Z", + finishedAt: "2026-08-29T12:01:00.000Z", + exitCode: 0, + exitSignal: null, + }); + + expect(state.report).toBeUndefined(); + expect(state.progress).toBeUndefined(); + }); +}); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index d80800d00846..10b520489562 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -302,6 +302,106 @@ export const ActionResumeDelivery = Schema.Literals([ ]); export type ActionResumeDelivery = typeof ActionResumeDelivery.Type; +export const ActionReportOutcome = Schema.Literals(["success", "attention", "blocked"]); +export type ActionReportOutcome = typeof ActionReportOutcome.Type; + +const ActionReportShortText = TrimmedNonEmptyString.check(Schema.isMaxLength(280)); +const ActionReportDetailText = TrimmedNonEmptyString.check(Schema.isMaxLength(1_000)); +const ActionReportUrl = TrimmedNonEmptyString.check( + Schema.isMaxLength(2_048), + Schema.makeFilter((value) => { + try { + const parsed = new URL(value); + return ( + ((parsed.protocol === "http:" || parsed.protocol === "https:") && + parsed.username.length === 0 && + parsed.password.length === 0) || + "Action report URLs must use http(s) without embedded credentials" + ); + } catch { + return "Action report URLs must be valid absolute URLs"; + } + }), +); + +export const ActionReportSubject = Schema.Struct({ + type: TrimmedNonEmptyString.check(Schema.isMaxLength(64)), + id: TrimmedNonEmptyString.check(Schema.isMaxLength(160)), + revision: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(200))), + url: Schema.optional(ActionReportUrl), +}); +export type ActionReportSubject = typeof ActionReportSubject.Type; + +export const ActionReportArtifact = Schema.Struct({ + label: TrimmedNonEmptyString.check(Schema.isMaxLength(120)), + url: ActionReportUrl, +}); +export type ActionReportArtifact = typeof ActionReportArtifact.Type; + +export const ActionReport = Schema.Struct({ + version: Schema.Literal(1), + outcome: ActionReportOutcome, + summary: ActionReportShortText, + reason: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(120))), + subject: Schema.optional(ActionReportSubject), + facts: Schema.optional( + Schema.Record( + TrimmedNonEmptyString.check(Schema.isMaxLength(80)), + TrimmedNonEmptyString.check(Schema.isMaxLength(500)), + ).check( + Schema.makeFilter( + (facts) => Object.keys(facts).length <= 16 || "Action reports may contain at most 16 facts", + ), + ), + ), + artifacts: Schema.optional(Schema.Array(ActionReportArtifact).check(Schema.isMaxLength(8))), +}); +export type ActionReport = typeof ActionReport.Type; + +export const ActionProgressState = Schema.Literals(["working", "waiting"]); +export type ActionProgressState = typeof ActionProgressState.Type; + +const ActionProgressFields = Schema.Struct({ + version: Schema.Literal(1), + state: ActionProgressState, + summary: ActionReportShortText, + phase: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(80))), + detail: Schema.optional(ActionReportDetailText), + current: Schema.optional(NonNegativeInt), + total: Schema.optional(PositiveInt), + unit: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(40))), +}); +export const ActionProgress = ActionProgressFields.check( + Schema.makeFilter( + (progress) => + progress.current === undefined || + progress.total === undefined || + progress.current <= progress.total || + "Action progress current value cannot exceed its total", + ), +); +export type ActionProgress = typeof ActionProgress.Type; + +const ActionProtocolEventFields = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("progress"), + progress: ActionProgress, + }), + Schema.Struct({ + kind: Schema.Literal("result"), + report: ActionReport, + }), +]); +export const ACTION_PROTOCOL_EVENT_MAX_JSON_CHARS = 10_000; +export const ActionProtocolEvent = ActionProtocolEventFields.check( + Schema.makeFilter( + (event) => + JSON.stringify(event).length <= ACTION_PROTOCOL_EVENT_MAX_JSON_CHARS || + `Action protocol events may contain at most ${ACTION_PROTOCOL_EVENT_MAX_JSON_CHARS} serialized characters`, + ), +); +export type ActionProtocolEvent = typeof ActionProtocolEvent.Type; + /** * Durable one-shot state recorded in `action.resume.lifecycle` thread * activities. The shell only exposes the latest row; the full activity @@ -322,6 +422,10 @@ export const ActionResumeState = Schema.Struct({ finishedAt: Schema.NullOr(IsoDateTime), exitCode: Schema.NullOr(Schema.Int), exitSignal: Schema.NullOr(Schema.Int), + /** Latest schema-validated progress emitted by a protocol-aware Action. */ + progress: Schema.optional(ActionProgress), + /** Terminal domain result emitted by a protocol-aware Action. */ + report: Schema.optional(ActionReport), }); export type ActionResumeState = typeof ActionResumeState.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 9c7dbbdea502..a39b873dd803 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -107,6 +107,10 @@ "types": "./src/actionResume.ts", "import": "./src/actionResume.ts" }, + "./actionResumeProtocol": { + "types": "./src/actionResumeProtocol.ts", + "import": "./src/actionResumeProtocol.ts" + }, "./threadEnvMode": { "types": "./src/threadEnvMode.ts", "import": "./src/threadEnvMode.ts" diff --git a/packages/shared/src/actionResumeProtocol.test.ts b/packages/shared/src/actionResumeProtocol.test.ts new file mode 100644 index 000000000000..76ec1df81293 --- /dev/null +++ b/packages/shared/src/actionResumeProtocol.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + ACTION_EVENT_TOKEN_ENV, + ACTION_RUN_ID_ENV, + actionProtocolFrame, + createActionProtocolDecoder, + createActionReporter, +} from "./actionResumeProtocol.ts"; + +const runId = "run-1"; +const token = "token-1"; + +describe("Action resume protocol", () => { + it("round-trips framed events split across terminal chunks without leaking the frame", () => { + const frame = actionProtocolFrame({ + runId, + token, + event: { + kind: "result", + report: { + version: 1, + outcome: "attention", + reason: "review-findings", + summary: "Two review findings need changes", + subject: { type: "pull-request", id: "144", revision: "abc123" }, + }, + }, + }); + const decoder = createActionProtocolDecoder({ runId, token }); + const first = decoder.push(`before\n${frame.slice(0, 20)}`); + const second = decoder.push(`${frame.slice(20)}after\n`); + + expect(`${first.output}${second.output}${decoder.finish()}`).toBe("before\nafter\n"); + expect([...first.events, ...second.events]).toEqual([ + { + kind: "result", + report: { + version: 1, + outcome: "attention", + reason: "review-findings", + summary: "Two review findings need changes", + subject: { type: "pull-request", id: "144", revision: "abc123" }, + }, + }, + ]); + }); + + it("leaves frames for another run in ordinary output", () => { + const foreignFrame = actionProtocolFrame({ + runId: "run-2", + token: "token-2", + event: { + kind: "progress", + progress: { version: 1, state: "waiting", summary: "Waiting for CI" }, + }, + }); + const decoder = createActionProtocolDecoder({ runId, token }); + const decoded = decoder.push(foreignFrame); + + expect(decoded.events).toEqual([]); + expect(`${decoded.output}${decoder.finish()}`).toBe(foreignFrame); + }); + + it("emits framed reports in LastCode and readable fallback output elsewhere", () => { + const writes: string[] = []; + const reporter = createActionReporter({ + env: { [ACTION_RUN_ID_ENV]: runId, [ACTION_EVENT_TOKEN_ENV]: token }, + write: (data) => writes.push(data), + }); + reporter.progress({ state: "working", summary: "Running tests", current: 1, total: 3 }); + reporter.result({ outcome: "success", summary: "All checks passed" }); + + const decoder = createActionProtocolDecoder({ runId, token }); + const decoded = decoder.push(writes.join("")); + expect(decoded.events.map((event) => event.kind)).toEqual(["progress", "result"]); + + const logs: string[] = []; + createActionReporter({ + env: {}, + write: () => undefined, + log: (line) => logs.push(line), + }).result({ outcome: "blocked", summary: "Authentication is required" }); + expect(logs[0]).toContain("[lastcode-action] Result:"); + expect(logs[0]).toContain("Authentication is required"); + }); + + it("drops malformed matching frames and reports them", () => { + const decoder = createActionProtocolDecoder({ runId, token }); + const decoded = decoder.push( + `\u001b]777;T3ActionEvent;${runId};${token};not-valid-base64\u0007visible`, + ); + + expect(decoded.events).toEqual([]); + expect(decoded.invalidFrames).toBe(1); + expect(`${decoded.output}${decoder.finish()}`).toBe("visible"); + }); + + it("rejects oversized events before writing a frame", () => { + expect(() => + actionProtocolFrame({ + runId, + token, + event: { + kind: "result", + report: { + version: 1, + outcome: "success", + summary: "Artifacts are ready", + artifacts: Array.from({ length: 8 }, (_, index) => ({ + label: `Artifact ${index}`, + url: `https://example.com/${"x".repeat(1_900)}${index}`, + })), + }, + }, + }), + ).toThrow("serialized characters"); + }); +}); diff --git a/packages/shared/src/actionResumeProtocol.ts b/packages/shared/src/actionResumeProtocol.ts new file mode 100644 index 000000000000..648af009b111 --- /dev/null +++ b/packages/shared/src/actionResumeProtocol.ts @@ -0,0 +1,129 @@ +import { ActionProtocolEvent, type ActionProgress, type ActionReport } from "@t3tools/contracts"; +import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +export const ACTION_PROTOCOL_VERSION = 1; +export const ACTION_PROTOCOL_OSC = "777;T3ActionEvent"; +export const ACTION_RUN_ID_ENV = "T3CODE_ACTION_RUN_ID"; +export const ACTION_EVENT_TOKEN_ENV = "T3CODE_ACTION_EVENT_TOKEN"; + +const ACTION_EVENT_MAX_ENCODED_CHARS = 16_384; +const decodeActionProtocolEventJson = Schema.decodeUnknownOption( + Schema.fromJsonString(ActionProtocolEvent), +); +const validateActionProtocolEvent = Schema.decodeUnknownSync(ActionProtocolEvent); + +export interface ActionProtocolDecoder { + readonly push: (data: string) => { + readonly output: string; + readonly events: ReadonlyArray; + readonly invalidFrames: number; + }; + readonly finish: () => string; +} + +export function actionProtocolFrame(input: { + readonly runId: string; + readonly token: string; + readonly event: ActionProtocolEvent; +}): string { + const event = validateActionProtocolEvent(input.event); + const payload = Encoding.encodeBase64Url(JSON.stringify(event)); + if (payload.length > ACTION_EVENT_MAX_ENCODED_CHARS) { + throw new Error("Action protocol event exceeds the encoded transport limit."); + } + return `\u001b]${ACTION_PROTOCOL_OSC};${input.runId};${input.token};${payload}\u0007`; +} + +function decodeActionProtocolPayload(payload: string): ActionProtocolEvent | null { + if (payload.length === 0 || payload.length > ACTION_EVENT_MAX_ENCODED_CHARS) return null; + const decoded = Encoding.decodeBase64UrlString(payload); + if (!Result.isSuccess(decoded)) return null; + return Option.getOrNull(decodeActionProtocolEventJson(decoded.success)); +} + +export function createActionProtocolDecoder(input: { + readonly runId: string; + readonly token: string; +}): ActionProtocolDecoder { + const prefix = `\u001b]${ACTION_PROTOCOL_OSC};${input.runId};${input.token};`; + let pending = ""; + + const push: ActionProtocolDecoder["push"] = (data) => { + pending += data; + let output = ""; + const events: ActionProtocolEvent[] = []; + let invalidFrames = 0; + + while (pending.length > 0) { + const startIndex = pending.indexOf(prefix); + if (startIndex === -1) { + const safeLength = Math.max(0, pending.length - (prefix.length - 1)); + output += pending.slice(0, safeLength); + pending = pending.slice(safeLength); + break; + } + + output += pending.slice(0, startIndex); + const payloadStart = startIndex + prefix.length; + const endIndex = pending.indexOf("\u0007", payloadStart); + if (endIndex === -1) { + if (pending.length - payloadStart <= ACTION_EVENT_MAX_ENCODED_CHARS) { + pending = pending.slice(startIndex); + break; + } + invalidFrames += 1; + pending = pending.slice(payloadStart); + continue; + } + + const event = decodeActionProtocolPayload(pending.slice(payloadStart, endIndex)); + if (event === null) invalidFrames += 1; + else events.push(event); + pending = pending.slice(endIndex + 1); + } + + return { output, events, invalidFrames }; + }; + + return { + push, + finish: () => { + const output = pending; + pending = ""; + return output; + }, + }; +} + +type ReporterEnvironment = Readonly>; + +export function createActionReporter(input: { + readonly env: ReporterEnvironment; + readonly write: (data: string) => void; + readonly log?: (message: string) => void; +}) { + const runId = input.env[ACTION_RUN_ID_ENV]; + const token = input.env[ACTION_EVENT_TOKEN_ENV]; + + const emit = (event: ActionProtocolEvent) => { + if (runId && token) { + input.write(actionProtocolFrame({ runId, token, event })); + return; + } + input.log?.( + `[lastcode-action] ${event.kind === "progress" ? "Progress" : "Result"}: ${JSON.stringify(event.kind === "progress" ? event.progress : event.report)}`, + ); + }; + + return { + progress(progress: Omit): void { + emit({ kind: "progress", progress: { version: ACTION_PROTOCOL_VERSION, ...progress } }); + }, + result(report: Omit): void { + emit({ kind: "result", report: { version: ACTION_PROTOCOL_VERSION, ...report } }); + }, + }; +} diff --git a/scripts/lastcode-build-intel-package.ts b/scripts/lastcode-build-intel-package.ts index 5fe87399cc57..1369b3bc5839 100644 --- a/scripts/lastcode-build-intel-package.ts +++ b/scripts/lastcode-build-intel-package.ts @@ -7,6 +7,7 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import { acquirePortableLock } from "./lastcode-lock.mjs"; +import { lastCodeAction } from "./lib/lastcode-action-kit.ts"; const DEFAULT_REPOSITORY = "lastobelus/lastCode"; const DEFAULT_REMOTE = "origin"; @@ -490,6 +491,19 @@ async function main(): Promise { } const result = await runSelectedIntelBuild(); console.log(`[build-intel] Result ${JSON.stringify(result)}`); + lastCodeAction.result({ + outcome: "success", + summary: `Intel package ${result.tag} is ready`, + subject: { type: "release", id: result.tag, revision: result.commit, url: result.releaseUrl }, + facts: { + workflowCommit: result.workflowCommit, + assets: result.assets.join(", "), + }, + artifacts: [ + { label: "Release", url: result.releaseUrl }, + { label: "Workflow run", url: result.runUrl }, + ], + }); } if (import.meta.main) { diff --git a/scripts/lastcode-local-ci.ts b/scripts/lastcode-local-ci.ts index f9bf012426b3..5eda3b8f7a04 100644 --- a/scripts/lastcode-local-ci.ts +++ b/scripts/lastcode-local-ci.ts @@ -7,6 +7,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { cleanGitEnvironment, parseLastCodeInstallableTag } from "./lastcode-nightly.ts"; +import { lastCodeAction } from "./lib/lastcode-action-kit.ts"; export const LASTCODE_BASE_BRANCH = "lastcode/main"; export const LASTCODE_ORIGIN_REMOTE = "origin"; @@ -812,6 +813,12 @@ function executeLocalCi( console.log(`\n[lastcode:ci] Full local CI passed for ${commitBefore}.`); console.log(`[lastcode:ci] Stamp: ${stampPath}`); console.log(formatLocalCiSummary("full", commitBefore)); + lastCodeAction.result({ + outcome: "success", + summary: `Full local CI passed for ${commitBefore}`, + subject: { type: "commit", id: commitBefore, revision: commitBefore }, + facts: { mode: "full" }, + }); } else if (options.mode === "quick" && baseCommit && quickBase) { const receiptPath = writeVerifiedQuickCiReceipt(repoRoot, repositoryIntegrity, { commit: commitBefore, @@ -822,6 +829,12 @@ function executeLocalCi( console.log(`\n[lastcode:ci] Quick local CI passed for ${commitBefore}.`); console.log(`[lastcode:ci] Receipt: ${receiptPath}`); console.log(formatLocalCiSummary("quick", commitBefore, baseCommit)); + lastCodeAction.result({ + outcome: "success", + summary: `Quick local CI passed for ${commitBefore}`, + subject: { type: "commit", id: commitBefore, revision: commitBefore }, + facts: { mode: "quick", baseCommit }, + }); } } diff --git a/scripts/lastcode-wait-for-pr.ts b/scripts/lastcode-wait-for-pr.ts index 3f406f15eb70..1515fdad7403 100644 --- a/scripts/lastcode-wait-for-pr.ts +++ b/scripts/lastcode-wait-for-pr.ts @@ -4,6 +4,7 @@ import * as NodeChildProcess from "node:child_process"; import { type GithubCiEvidence, readGithubCi } from "./lastcode-github-ci.ts"; +import { lastCodeAction } from "./lib/lastcode-action-kit.ts"; const LASTCODE_GITHUB_REPOSITORY = process.env.LASTCODE_GITHUB_REPOSITORY ?? "lastobelus/lastCode"; const LASTCODE_BASE_BRANCH = "lastcode/main"; @@ -782,6 +783,27 @@ async function main(): Promise { } if (decision.kind === "wake") { console.log(formatWaitForPrSummary(decision, current)); + lastCodeAction.result({ + outcome: decision.reason === "ready" ? "success" : "attention", + reason: decision.reason, + summary: decision.detail, + subject: { + type: "pull-request", + id: String(current.pullRequest.number), + revision: current.pullRequest.headRefOid, + url: current.pullRequest.url, + }, + facts: { + base: current.pullRequest.baseRefOid, + ci: current.ci.state, + review: current.review.pending + ? "pending" + : current.review.ready + ? "completed" + : "attention", + }, + artifacts: [{ label: "Pull request", url: current.pullRequest.url }], + }); return; } diff --git a/scripts/lib/lastcode-action-kit.ts b/scripts/lib/lastcode-action-kit.ts new file mode 100644 index 000000000000..6604ec28269d --- /dev/null +++ b/scripts/lib/lastcode-action-kit.ts @@ -0,0 +1,11 @@ +import { createActionReporter } from "@t3tools/shared/actionResumeProtocol"; + +/** + * Structured reporting for LastCode's repository-owned resumable Project Actions. + * Outside a resumable run, reports remain readable ordinary terminal output. + */ +export const lastCodeAction = createActionReporter({ + env: process.env, + write: (data) => process.stdout.write(data), + log: (message) => process.stdout.write(`${message}\n`), +});