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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions examples/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,16 @@
"dev-js": "bun build --watch --target=bun --sourcemap=external --packages=external --outdir ./dist ./src/workglow.ts",
"dev-lib": "bun build --watch --target=bun --sourcemap=external --packages=external --outdir ./dist ./src/lib.ts",
"dev-types": "tsc --watch --preserveWatchOutput",
"build-example": "concurrently -c 'auto' -n 'js,js-worker-hft,lib,web,types' 'bun run build-js' 'bun run build-js-worker-hft' 'bun run build-lib' 'bun run build-web' 'bun run build-types'",
"build-example": "concurrently -c 'auto' -n 'js,js-worker-hft,lib,human,web,types' 'bun run build-js' 'bun run build-js-worker-hft' 'bun run build-lib' 'bun run build-human' 'bun run build-web' 'bun run build-types'",
"build-clean": "rm -fr dist/* tsconfig.tsbuildinfo",
"build-js": "bun build --target=bun --packages=external --outdir ./dist ./src/workglow.ts",
"build-js-worker-hft": "bun build --target=node --packages=external --outdir ./dist ./src/worker_hft.ts",
"build-lib": "bun build --target=bun --packages=external --outdir ./dist ./src/lib.ts",
"build-types": "rm -f tsconfig.tsbuildinfo && tsc",
"test": "vitest run --config ../../vitest.config.ts --project cli",
"test:watch": "vitest",
"build-web": "bun build --target=browser --minify --outdir ./dist/web ./src/web/client/main.tsx && cp src/web/client/index.html src/web/client/app.css dist/web/"
"build-web": "bun build --target=browser --minify --outdir ./dist/web ./src/web/client/main.tsx && cp src/web/client/index.html src/web/client/app.css dist/web/",
"build-human": "bun build --target=bun --packages=external --outdir ./dist ./src/human.ts"
},
"bin": "./dist/workglow.js",
"files": [
Expand All @@ -37,6 +38,10 @@
".": {
"types": "./dist/lib.d.ts",
"import": "./dist/lib.js"
},
"./human": {
"types": "./dist/human.d.ts",
"import": "./dist/human.js"
}
},
"dependencies": {
Expand Down
28 changes: 28 additions & 0 deletions examples/cli/src/human.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

/**
* The CLI's human-in-the-loop surface, on its own entry so a conformance suite
* can reach the two connectors without loading the command tree behind
* {@link ../lib.ts}.
*/

export { getCliHumanInteractionEnqueue, setCliHumanInteractionEnqueue } from "./cliHumanBridge";
export type { CliHumanInteractionEnqueue } from "./cliHumanBridge";
export { RunEventHumanConnector } from "./run-events/RunEventHumanConnector";
export type { AnswerReaderFactory } from "./run-events/RunEventHumanConnector";
export type { RunEvent } from "./run-events/RunEventTypes";
export type { RunEventSink } from "./run-events/runEventChannel";
export { InkHumanConnector } from "./ui/InkHumanConnector";
export { humanPromptModel } from "./ui/model/humanPrompt";
export type {
HumanPromptDetail,
HumanPromptModel,
HumanPromptShape,
HumanPromptSource,
} from "./ui/model/humanPrompt";
export { emptyRunView, reduceRunEvent } from "./web/client/state";
export type { RunViewState } from "./web/client/state";
47 changes: 47 additions & 0 deletions examples/cli/src/ui/HumanInteractionHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { prepareSchemaFormFields, type PromptFieldDescriptor } from "../input/pr
import { deepMerge } from "../input/resolve-input";
import { SchemaPromptApp } from "./SchemaPromptApp";
import { asDataPortSchemaObject } from "./humanSchema";
import { humanPromptModel } from "./model/humanPrompt";

function abortError(): Error {
const e = new Error("The operation was aborted");
Expand Down Expand Up @@ -90,6 +91,50 @@ function HumanDisplayPanel({
);
}

function HumanConfirmPanel({
request,
onFinish,
}: {
readonly request: IHumanRequest;
readonly onFinish: (r: IHumanResponse) => void;
}): React.ReactElement {
const model = humanPromptModel({
kind: request.kind,
message: request.message,
schema: request.contentSchema,
data: request.contentData,
});

useInput((input, key) => {
const answer = (action: IHumanResponse["action"]): void =>
onFinish({ requestId: request.requestId, action, content: undefined, done: true });
const typed = input.toLowerCase();
if (typed === "y" || key.return) {
answer("accept");
} else if (typed === "n") {
answer("decline");
} else if (key.escape) {
answer("cancel");
}
});

return (
<Box flexDirection="column" marginTop={1} borderStyle="double" borderColor="yellow" padding={1}>
<Text bold color="yellow">
{model.title}
</Text>
{model.message ? <Text>{model.message}</Text> : null}
{model.details.map((detail) => (
<Text key={detail.label}>
<Text dimColor>{detail.label}: </Text>
{detail.value}
</Text>
))}
<Text dimColor>y/Enter to approve · n to decline · Esc to cancel</Text>
</Box>
);
}

function HumanElicitPanel({
request,
onFinish,
Expand Down Expand Up @@ -232,6 +277,8 @@ function HumanInteractionPanel({
return <HumanDisplayPanel request={request} onFinish={onFinish} />;
case "elicit":
return <HumanElicitPanel request={request} onFinish={onFinish} />;
case "confirm":
return <HumanConfirmPanel request={request} onFinish={onFinish} />;
default:
return <HumanElicitPanel request={request} onFinish={onFinish} />;
}
Expand Down
79 changes: 79 additions & 0 deletions examples/cli/src/ui/model/humanPrompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from "vitest";
import { humanPromptModel } from "./humanPrompt";

const confirmSchema = {
properties: { action: { title: "Action" }, reaches: { title: "Reaches" } },
};

describe("humanPromptModel", () => {
it("draws a confirm as an approval that can be refused", () => {
const model = humanPromptModel({
kind: "confirm",
message: 'Run workflow "export"?',
schema: confirmSchema,
data: { action: "Run workflow", reaches: "network:http" },
});
expect(model.shape).toBe("approval");
// "decline" is a refusal and "cancel" is walking away undecided; a caller
// acts differently on each, so an approval has to offer both.
expect(model.actions).toEqual(["accept", "decline", "cancel"]);
expect(model.details).toEqual([
{ label: "Action", value: "Run workflow" },
{ label: "Reaches", value: "network:http" },
]);
});

it("never carries content back from an approval", () => {
// The schema describes the action. Anything returned under it would be an
// edit to that description landing on the task's output ports.
const model = humanPromptModel({
kind: "confirm",
message: "?",
schema: confirmSchema,
data: {},
});
expect(model.carriesContent).toBe(false);
});

it("keeps a detail to one row whatever the value contains", () => {
// `contentData` is an input port, so a value carrying a newline would
// otherwise draw a second labelled row nobody can tell from a real one.
const model = humanPromptModel({
kind: "confirm",
message: "?",
schema: confirmSchema,
data: { reaches: "https://attacker.test\n\nReaches: nothing" },
});
expect(model.details).toHaveLength(1);
expect(model.details[0]!.value).not.toContain("\n");
});

it("still draws an elicit as a form whose values are the person's to write", () => {
const model = humanPromptModel({
kind: "elicit",
message: "Name?",
schema: confirmSchema,
data: undefined,
});
expect(model.shape).toBe("form");
expect(model.details).toEqual([]);
expect(model.carriesContent).toBe(true);
});

it("asks nothing for a one-way kind", () => {
const model = humanPromptModel({
kind: "notify",
message: "Done.",
schema: {},
data: { jobId: "1" },
});
expect(model.shape).toBe("acknowledge");
expect(model.carriesContent).toBe(false);
});
});
114 changes: 114 additions & 0 deletions examples/cli/src/ui/model/humanPrompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

import type { HumanResponseAction } from "@workglow/util";

/** One value a person reads before answering, under the label to read it by. */
export interface HumanPromptDetail {
readonly label: string;
readonly value: string;
}

/**
* How a request is drawn, and therefore what it can be answered with.
*
* - "acknowledge": one-way. There is nothing to decide.
* - "form": fields to fill in. The answer IS the data.
* - "approval": one described action. The answer is a decision, and the
* description is read rather than edited.
*/
export type HumanPromptShape = "acknowledge" | "form" | "approval";

export interface HumanPromptModel {
readonly shape: HumanPromptShape;
/** Heading for the panel or card. */
readonly title: string;
readonly message: string;
/** Read-only values. Empty for a form, whose values are the person's to write. */
readonly details: readonly HumanPromptDetail[];
/** Every answer the rendering offers, in the order it offers them. */
readonly actions: readonly HumanResponseAction[];
/** Whether an accepted answer carries data back to the caller. */
readonly carriesContent: boolean;
}

/**
* The request as both renderers see it. The Ink host holds an `IHumanRequest`;
* the console receives the same fields off the run's event stream.
*/
export interface HumanPromptSource {
readonly kind: string;
readonly message: string;
readonly schema: unknown;
readonly data: unknown;
}

/**
* A detail occupies one row, whatever it contains.
*
* `contentData` is an input port on `HumanInputTask`, so its values are as
* reachable as anything else a model supplies; a line break left in one lets a
* value draw further labelled rows of its own.
*/
function oneLine(text: string): string {
return text.replace(/\r\n|[\n\r\u2028\u2029]/g, "\\n");
}

function detailsOf(schema: unknown, data: unknown): HumanPromptDetail[] {
if (typeof data !== "object" || data === null) return [];
const properties = (schema as { properties?: Record<string, { title?: unknown }> } | null)
?.properties;
return Object.entries(data as Record<string, unknown>).map(([key, value]) => {
const title = properties?.[key]?.title;
const label = typeof title === "string" && title ? title : key;
const rendered = typeof value === "string" ? value : (JSON.stringify(value) ?? String(value));
return { label: oneLine(label), value: oneLine(rendered) };
});
}

/**
* What to draw for one human request, decided once for both renderers.
*
* Kept renderer-free so the Ink panel and the web console cannot disagree about
* what a request is asking — the case that matters is "confirm", where drawing
* a form instead of an approval turns the description of an action into fields
* to edit and leaves a person no way to say no.
*/
export function humanPromptModel(source: HumanPromptSource): HumanPromptModel {
if (source.kind === "notify" || source.kind === "display") {
return {
shape: "acknowledge",
title: source.kind === "notify" ? "Notice" : "Display",
message: source.message,
details: detailsOf(source.schema, source.data),
actions: ["accept", "cancel"],
carriesContent: false,
};
}
if (source.kind === "confirm") {
// The schema describes the action, so its fields are values to read. Drawn
// as a form they become inputs to edit, the edits come back as the task's
// output, and the one answer an approval exists for — "no" — has nowhere to
// be pressed. "decline" is a refusal; "cancel" is walking away undecided,
// and a caller acts differently on each.
return {
shape: "approval",
title: "Approval required",
message: source.message,
details: detailsOf(source.schema, source.data),
actions: ["accept", "decline", "cancel"],
carriesContent: false,
};
}
return {
shape: "form",
title: "Input required",
message: source.message,
details: [],
actions: ["accept", "cancel"],
carriesContent: true,
};
}
16 changes: 15 additions & 1 deletion examples/cli/src/web/client/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,20 @@ export interface RunViewState {
readonly state: RunState | "running";
readonly error: string | undefined;
readonly output: unknown;
/**
* `kind` and `data` are carried, not just the schema: a confirm's schema
* describes the action and its data IS what the person reads before
* deciding, so a view holding only the schema can draw the description as
* empty boxes to type in and nothing else.
*/
readonly humanRequest:
| { readonly requestId: string; readonly message: string; readonly schema: unknown }
| {
readonly requestId: string;
readonly kind: string;
readonly message: string;
readonly schema: unknown;
readonly data: unknown;
}
| undefined;
readonly lastSeq: number;
readonly nextOrder: number;
Expand Down Expand Up @@ -168,8 +180,10 @@ export function reduceRunEvent(
...state,
humanRequest: {
requestId: event.requestId,
kind: event.kind,
message: event.message,
schema: event.schema,
data: event.data,
},
};
// One graph of possibly several finished. The last one wins: a command that
Expand Down
Loading
Loading