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
73 changes: 45 additions & 28 deletions web/lib/flow-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ export function localInput(draft: FactoryDraft) {
source, title: contains?.trim() || PLACEHOLDER_TITLE,
body: PLACEHOLDER_BODY,
labels: labels?.split(',').map(label => label.trim()).filter(Boolean) ?? [],
// Cloud supplies this from provider metadata. A local GitHub run must ask
// for the real number rather than manufacture one on the user's behalf.
...(source === 'github' ? { identifier: '' } : {}),
...fields,
} };
}
Expand Down Expand Up @@ -386,43 +389,57 @@ if (existsSync(INPUT)) {
// body is the sentinel, not title: a "contains" filter overwrites title
// when the kit is built, so only body is reliably the placeholder.
const untouched = issue.body === PLACEHOLDER_BODY;
if (untouched && !process.stdin.isTTY) {
fail(INPUT + " still holds the placeholder ticket.",
"Set issue.title and issue.body to the real ticket, then run again.",
const missingGithubIdentifier = issue.source === "github" && !/^#[1-9]\\d*$/.test((issue.identifier || "").trim());
if ((untouched || missingGithubIdentifier) && !process.stdin.isTTY) {
fail(INPUT + " does not contain complete ticket metadata.",
"Set issue.title and issue.body to the real ticket and, for GitHub, set issue.identifier to #<number>.",
"Nothing here can be asked without a terminal, so the run stops rather",
"than sending a coding agent after " + JSON.stringify(PLACEHOLDER_TITLE) + ".");
}
if (untouched) {
if (untouched || missingGithubIdentifier) {
const { rl, ask } = prompter();
try {
console.log(INPUT + " still holds the placeholder ticket. Fill it in now.");
console.log(INPUT + " needs complete ticket metadata. Fill it in now.");
console.log("");
let title = "";
while (!title) {
const answer = await ask("Ticket title: ");
// Ctrl+D or a closed pipe at the prompt ends with the same advice as
// the no-terminal path, not an unhandled rejection.
if (answer === null) {
fail("the ticket was not entered.",
"Set issue.title and issue.body in " + INPUT + ", then run again.");
if (untouched) {
let title = "";
while (!title) {
const answer = await ask("Ticket title: ");
// Ctrl+D or a closed pipe at the prompt ends with the same advice as
// the no-terminal path, not an unhandled rejection.
if (answer === null) {
fail("the ticket was not entered.",
"Set issue.title and issue.body in " + INPUT + ", then run again.");
}
title = answer.trim();
if (!title) console.log(" A title is required.");
}
title = answer.trim();
if (!title) console.log(" A title is required.");
}
console.log("Description and acceptance criteria. Finish with an empty line.");
const lines = [];
for (;;) {
const line = await ask("> ");
// Ctrl+D ends the description, exactly as the empty line does.
if (line === null || !line.trim()) break;
lines.push(line);
console.log("Description and acceptance criteria. Finish with an empty line.");
const lines = [];
for (;;) {
const line = await ask("> ");
// Ctrl+D ends the description, exactly as the empty line does.
if (line === null || !line.trim()) break;
lines.push(line);
}
const body = lines.join("\\n").trim();
if (!body) {
fail("no description was entered.",
"Run again and describe the work, or edit " + INPUT + " by hand.");
}
input.issue = { ...issue, title, body };
}
const body = lines.join("\\n").trim();
if (!body) {
fail("no description was entered.",
"Run again and describe the work, or edit " + INPUT + " by hand.");
if (missingGithubIdentifier) {
let identifier = "";
while (!/^#[1-9]\\d*$/.test(identifier)) {
const answer = await ask("GitHub issue number (for example #507): ");
if (answer === null) fail("the GitHub issue number was not entered.",
"Set issue.identifier to #<number> in " + INPUT + ", then run again.");
identifier = answer.trim();
if (!/^#[1-9]\\d*$/.test(identifier)) console.log(" Use # followed by the issue number.");
}
input.issue = { ...input.issue, identifier };
}
input.issue = { ...issue, title, body };
writeFileSync(INPUT, JSON.stringify(input, null, 2) + "\\n");
console.log("");
console.log("Saved to " + INPUT + ".");
Expand Down
4 changes: 4 additions & 0 deletions web/lib/flow-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ export type Issue = {
title: string;
body: string;
labels: string[];${optional}
// Normalized by Cloud for every connected ticket provider. A local input
// may leave either field blank when that provider has no such metadata.
identifier?: string;
url?: string;
};`;
}

Expand Down
74 changes: 71 additions & 3 deletions web/lib/flow-workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,36 @@ export const FLOW_DROP_WORKING_FILES_COMMAND = [
export const FLOW_OPEN_CHANGE_COMMAND =
'open_change() { if command -v relayflow-open-change >/dev/null 2>&1; then relayflow-open-change "$@"; else gh pr create "$@"; fi; }; open_change';

/**
* Adds the deterministic provider reference after the generated check report.
* The reference is supplied through a shell-quoted variable by the generated
* flow; grep matches a complete, fixed line so an agent-written matching line
* is retained rather than duplicated.
*/
export const FLOW_PREPARE_CHANGE_METADATA_COMMAND = [
'if [ ! -s .relayflow/pr-body.md ]; then echo missing-body; exit 0; fi',
'if [ -n "$reference" ] && ! grep -qxF "$reference" .relayflow/pr-body.md; then printf "\\n%s\\n" "$reference" >> .relayflow/pr-body.md; fi',
'echo prepared',
].join('; ');

/**
* Final fail-closed contract immediately before a branch is pushed or a
* change request is opened. It deliberately exits zero with one verdict: an
* invalid verdict is handled by the flow instead of being retried as a flaky
* command. GitHub inputs must have exactly one normalized closing line.
*/
export const FLOW_VALIDATE_CHANGE_METADATA_COMMAND = [
'if [ ! -s .relayflow/pr-body.md ]; then echo missing-body',
'elif [ -z "$title" ]; then echo empty-title',
'elif ! printf "%s\\n" "$title_length" | grep -Eq "^[0-9]+$"; then echo malformed-title-length',
'elif [ "$title_length" -gt 240 ]; then echo title-too-long',
'elif [ "$(printf %s "$title" | tr "[:upper:]" "[:lower:]")" = "software factory change" ] || [ "$(printf %s "$title" | tr "[:upper:]" "[:lower:]")" = "replace with your ticket title" ]; then echo placeholder-title',
'elif [ "$source" = github ] && ! printf "%s\\n" "$identifier" | grep -Eq "^#[1-9][0-9]*$"; then echo malformed-github-identifier',
'elif [ "$source" = github ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" .relayflow/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-github-closing-reference; elif [ "$count" -ne 1 ]; then echo duplicate-github-closing-reference; else echo valid; fi',
'else echo valid',
'fi',
].join('; ');

/**
* Decides whether there is anything to publish, before the branch is pushed and
* before `gh pr create` runs.
Expand Down Expand Up @@ -411,7 +441,36 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType<typeof wor
return `cli: ${cli},${value.model ? `\n model: ${JSON.stringify(value.model)},` : ''}\n task: task + "\\n" + ${JSON.stringify(value.prompt)}${context},`;
};
const prototypeConfigs = (['prototype-1', 'prototype-2', 'prototype-3'] as const).map(config);
const sections = [{ id: 'task', code: ` const task = issue.title + "\\n" + issue.body + "\\n" +
const sections = [{ id: 'task', code: ` const normalizedTitle = issue.title.trim().replace(/\\s+/g, " ");
// Bound and measure by Unicode code points so neither truncation nor the
// final shell validation can split or byte-count a multibyte character.
const changeTitle = Array.from(normalizedTitle).slice(0, 240).join("").trim();
const changeTitleLength = Array.from(changeTitle).length;
const placeholderTitle = ["software factory change", "replace with your ticket title"]
.includes(changeTitle.toLowerCase());
const issueSource = issue.source.trim().toLowerCase();
const issueIdentifier = issue.identifier?.trim() ?? "";
const issueUrl = issue.url?.trim() ?? "";
if (!changeTitle || placeholderTitle) {
console.error("Stopped: the pull-request title is empty or still a placeholder. No branch was pushed and no pull request was opened.");
return f.done("needs_human");
}
if (issueSource === "github" && !/^#[1-9]\\d*$/.test(issueIdentifier)) {
console.error("Stopped: a GitHub ticket must carry its normalized identifier in #<number> form. No branch was pushed and no pull request was opened.");
return f.done("needs_human");
}
// GitHub receives its exact closing keyword. Other providers get a stable
// native reference when one is available; Markdown invents no identifier.
const changeReference = issueSource === "github"
? "Fixes " + issueIdentifier
: issueSource === "gitlab" && /^#[1-9]\\d*$/.test(issueIdentifier)
? "Closes " + issueIdentifier
: issueUrl
? "Ticket: " + issueUrl
: issueIdentifier
? "Ticket: " + issueIdentifier
: "";
const task = issue.title + "\\n" + issue.body + "\\n" +
${JSON.stringify(instructions.trim() || 'Follow existing patterns. Keep changes focused and add regression tests.')};
// Files the agents write for each other (summary.md, plans, reviews, and
// .relayflow/) are never part of the change; keep them out of every commit.
Expand Down Expand Up @@ -523,19 +582,28 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType<typeof wor
console.error("Stopped: the agents made no commits on this branch, so there is nothing to publish. No branch was pushed and no pull request was opened.");
return f.done("needs_human");
}
await f.run("git push --set-upstream origin HEAD");
if (publish !== "publish") {
await f.run("git push --set-upstream origin HEAD");
console.error("Stopped: the branch was pushed, but no summary.md was written, so there is no pull-request body. Open the pull request by hand, or run again.");
return f.done("needs_human");
}
// Failing checks never throw the work away: the pull request opens as a
// draft, with the verdict, the script and the output in its body.
const checkReport = ${JSON.stringify(FLOW_CHECK_REPORT_COMMAND)};
await f.run("check=" + check + "; baseline=" + verdictOf(baseline) + "; " + checkReport);
const prepareChangeMetadata = ${JSON.stringify(FLOW_PREPARE_CHANGE_METADATA_COMMAND)};
await f.run("reference=" + shellQuote(changeReference) + "; " + prepareChangeMetadata);
const validateChangeMetadata = ${JSON.stringify(FLOW_VALIDATE_CHANGE_METADATA_COMMAND)};
const metadataVerdict = (await f.run("title=" + shellQuote(changeTitle) + "; title_length=" + changeTitleLength + "; source=" + shellQuote(issueSource) + "; identifier=" + shellQuote(issueIdentifier) + "; " + validateChangeMetadata)).trim();
if (metadataVerdict !== "valid") {
console.error("Stopped: invalid pull-request metadata (" + metadataVerdict + "). No branch was pushed and no pull request was opened.");
return f.done("needs_human");
}
await f.run("git push --set-upstream origin HEAD");
// Hosted runs put relayflow-open-change on PATH: gh pr create on GitHub, a
// merge request on GitLab. A local run has only gh.
const openChange = ${JSON.stringify(FLOW_OPEN_CHANGE_COMMAND)};
await f.run(openChange + ' --title "Software factory change" --body-file .relayflow/pr-body.md' + (broken(check) ? " --draft" : ""));
await f.run(openChange + " --title " + shellQuote(changeTitle) + " --body-file .relayflow/pr-body.md" + (broken(check) ? " --draft" : ""));
if (broken(check) && (baseline === "pass" || baseline === "new")) {
// The base commit passes and this branch does not, or the checks are the
// change's own and fail: the change broke them and repair could not fix
Expand Down
5 changes: 3 additions & 2 deletions web/lib/test/flow-agent-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ts from 'typescript';
import { DEFAULT_FACTORY, factorySource, readFactoryDraft, cloudConnectionsHref, type FactoryDraft } from '../flow-onboarding';
import { resolveAgentSettings } from '../flow-agent-settings';
import { localKitFiles } from '../flow-local';
import { FLOW_VALIDATE_CHANGE_METADATA_COMMAND } from '../flow-workflows';

const draft: FactoryDraft = { ...DEFAULT_FACTORY, sources: ['github'], agents: ['claude', 'codex', 'cursor', 'opencode'], workflow: 'prototype', step: 3 };
async function execute(value: FactoryDraft) {
Expand All @@ -15,8 +16,8 @@ async function execute(value: FactoryDraft) {
// "base=..." is the publish check; without a verdict it understands, the
// flow correctly stops before the reviews rather than opening a pull
// request for work that was never committed.
run: async (command: string) => command.startsWith('base=') ? 'publish' : command.startsWith('mktemp') ? '/tmp/prototypes' : command.includes('review.clean &&') ? 'yes' : 'base', done: () => {} },
{ issue: { source: 'github', title: 'Ticket title', body: 'Ticket body', labels: [] } });
run: async (command: string) => command.endsWith(FLOW_VALIDATE_CHANGE_METADATA_COMMAND) ? 'valid' : command.startsWith('base=') ? 'publish' : command.startsWith('mktemp') ? '/tmp/prototypes' : command.includes('review.clean &&') ? 'yes' : 'base', done: () => {} },
{ issue: { source: 'github', title: 'Ticket title', body: 'Ticket body', labels: [], identifier: '#507' } });
return calls;
}

Expand Down
25 changes: 17 additions & 8 deletions web/lib/test/flow-local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { pathToFileURL } from 'node:url';
import ts from 'typescript';
import { DEFAULT_FACTORY, factorySource, type FactoryDraft } from '../flow-onboarding';
import { LOCAL_INSTALL, LOCAL_PREFLIGHT, LOCAL_RUN, PLACEHOLDER_BODY, PLACEHOLDER_TITLE, RELAYFLOWS_VERSION, localInput, localKitArchive, localKitFiles } from '../flow-local';
import { FLOW_BASE_CHECK_COMMAND, FLOW_CHECK_BLOCKED_COMMAND, FLOW_CHECK_RUN_COMMAND, FLOW_OPEN_CHANGE_COMMAND, FLOW_PUBLISH_CHECK_COMMAND } from '../flow-workflows';
import { FLOW_BASE_CHECK_COMMAND, FLOW_CHECK_BLOCKED_COMMAND, FLOW_CHECK_RUN_COMMAND, FLOW_OPEN_CHANGE_COMMAND, FLOW_PUBLISH_CHECK_COMMAND, FLOW_VALIDATE_CHANGE_METADATA_COMMAND } from '../flow-workflows';

/**
* What each deterministic step reports, keyed by the command itself: three
Expand All @@ -19,10 +19,17 @@ function answer(command: string, { publish = 'publish', clean = 'yes', check = '
if (command === FLOW_CHECK_RUN_COMMAND) return typeof check === 'function' ? check() : check;
if (command.endsWith(FLOW_BASE_CHECK_COMMAND)) return baseline;
if (command.endsWith(FLOW_PUBLISH_CHECK_COMMAND)) return publish;
if (command.endsWith(FLOW_VALIDATE_CHANGE_METADATA_COMMAND)) return 'valid';
return command.startsWith('test -f') ? clean : '';
}

const draft: FactoryDraft = { ...DEFAULT_FACTORY, sources: ['github'], sourceSettings: { github: { repository: 'acme/app', labels: 'bug, ready' } }, agents: ['claude', 'codex'], workflow: 'traditional', step: 3 };
const localRunInput = (selected = draft) => {
const input = localInput(selected);
return 'issue' in input ? { ...input, issue: {
...input.issue, title: 'Fix login', body: 'Users cannot sign in.', identifier: '#507',
} } : input;
};

/**
* The generated flow's comments name the completion reasons it deliberately
Expand Down Expand Up @@ -119,6 +126,7 @@ describe('local flow starter kit', () => {
// matching what localInput prefills, an unedited ticket reaches an agent.
expect(issue.body).toBe(PLACEHOLDER_BODY);
expect(issue.title).toBe(PLACEHOLDER_TITLE);
expect((issue as { identifier?: string }).identifier).toBe('');
expect(script).toContain(JSON.stringify(PLACEHOLDER_BODY));
// body, not title: a `contains` filter overwrites title at build time.
const filtered = { ...draft, sourceSettings: { github: { repository: 'acme/app', contains: 'Please fix' } } };
Expand All @@ -131,9 +139,10 @@ describe('local flow starter kit', () => {
it('prompts on a terminal and fails fast without one instead of hanging', () => {
const script = localKitFiles(draft)[LOCAL_PREFLIGHT];
expect(script).toContain('createInterface');
expect(script).toContain('untouched && !process.stdin.isTTY');
expect(script).toContain('(untouched || missingGithubIdentifier) && !process.stdin.isTTY');
// The non-interactive refusal has to name the file and both fields.
expect(script).toContain('Set issue.title and issue.body to the real ticket');
expect(script).toContain('issue.identifier to #<number>');
expect(script).toContain('writeFileSync(INPUT, JSON.stringify(input, null, 2)');
});

Expand Down Expand Up @@ -242,7 +251,7 @@ describe('local flow starter kit', () => {
agent: async (name: string) => { calls.push(name); },
run: async (command: string) => answer(command),
done: (reason: string) => { finish = reason; },
}, localInput(draft));
}, localRunInput());
expect(calls).toEqual(['planner', 'plan-reviewer', 'check-discovery', 'implementer', 'adversary-1', 'adversary-2']);
expect(finish).toBe('needs_human');
expect(factorySource(draft)).toContain('return f.done("needs_human")');
Expand All @@ -265,7 +274,7 @@ describe('local flow starter kit', () => {
agent: async () => {},
run: async (command: string) => { commands.push(command); return answer(command, { publish: 'no-commits' }); },
done: (reason: string) => { finish = reason; },
}, localInput(draft));
}, localRunInput());
} finally { console.error = original; }
expect(commands.some(command => command.startsWith(FLOW_OPEN_CHANGE_COMMAND))).toBe(false);
expect(commands.some(command => command.startsWith('git push'))).toBe(false);
Expand All @@ -282,7 +291,7 @@ describe('local flow starter kit', () => {
agent: async () => {},
run: async (command: string) => { commands.push(command); return answer(command); },
done: (reason: string) => { finish = reason; },
}, localInput(selected));
}, localRunInput(selected));
expect(finish).toBe('needs_human');
const testIndex = commands.indexOf(FLOW_CHECK_RUN_COMMAND);
const createIndex = commands.findIndex(command => command.startsWith(FLOW_OPEN_CHANGE_COMMAND));
Expand All @@ -304,7 +313,7 @@ describe('local flow starter kit', () => {
agent: async () => {},
run: async (command: string) => { commands.push(command); return answer(command, { check: 'fail', baseline: 'pass' }); },
done: (reason: string) => { finish = reason; },
}, localInput(selected));
}, localRunInput(selected));
const create = commands.find(command => command.startsWith(FLOW_OPEN_CHANGE_COMMAND)) ?? '';
expect(create).toContain('--draft');
expect(commands).toContain('git push --set-upstream origin HEAD');
Expand All @@ -326,7 +335,7 @@ describe('local flow starter kit', () => {
return answer(command, { clean: 'no', check: () => (++checks >= 2 && fail ? 'fail' : 'pass') });
},
done: (reason: string) => { finish = reason; },
}, localInput(draft));
}, localRunInput());
const fixer = calls.indexOf('fixer');
expect(fixer).toBeGreaterThan(0);
expect(calls[fixer + 1]).toBe(FLOW_CHECK_RUN_COMMAND);
Expand Down Expand Up @@ -531,7 +540,7 @@ describe('relocating a kit that was extracted outside a repository', () => {
}
if (options.ticket) {
writeFileSync(join(target, 'flow-input.json'), JSON.stringify({ approver: 'local',
issue: { source: 'github', title: 'Fix login', body: 'Users cannot sign in.', labels: ['bug', 'ready'], repository: 'acme/app' } }, null, 2) + '\n');
issue: { source: 'github', title: 'Fix login', body: 'Users cannot sign in.', labels: ['bug', 'ready'], repository: 'acme/app', identifier: '#507' } }, null, 2) + '\n');
}
return target;
}
Expand Down
Loading
Loading