Skip to content
Closed
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9af160e
fix(workflows): make replay journal durable
qqqys Aug 8, 2026
a562d8b
fix(workflows): address three of round 1's four Critical findings
qqqys Aug 8, 2026
2c735cf
fix(workflows): stop labelling refused journal reads as truncation
qqqys Aug 8, 2026
66424a8
fix(cli): sanitize every persisted string /workflows renders
qqqys Aug 9, 2026
d34f64d
fix(workflows): bound artifact reads and order the pause barrier
qqqys Aug 9, 2026
d51e4d1
fix(workflows): close the pause window while a run settles
qqqys Aug 9, 2026
78ea279
fix(workflows): close the settling latch on resume and stop pause fro…
qqqys Aug 9, 2026
5fcf726
chore(workflows): merge upstream/main into the durable-journal branch
qqqys Aug 9, 2026
fdc3cad
fix(workflows): bound durable reads and stop lossy round-trips
qqqys Aug 10, 2026
a5a4366
feat(core): give durable workflow runs a manifest-generation CAS
qqqys Aug 10, 2026
33f3885
fix(workflows): close the settling latch on pump and stop d_type-less…
qqqys Aug 10, 2026
3aef43e
test(workflows): truncate the committed journal prefix
qqqys Aug 10, 2026
23bbe16
Merge branch 'main' into codex/issue-8105-workflow-durable-journal
qwen-code-dev-bot Aug 10, 2026
c4862ae
Merge branch 'main' into codex/issue-8105-workflow-durable-journal
qwen-code-dev-bot Aug 10, 2026
8bfd654
Merge branch 'main' into codex/issue-8105-workflow-durable-journal
qwen-code-dev-bot Aug 10, 2026
0172874
fix(workflows): reject non-string manifest fields
qqqys Aug 11, 2026
b8d1500
fix(core): harden workflow durable-state writes against races and hos…
qwen-code-dev-bot Aug 11, 2026
ec532c6
fix(core): require a valid snapshot before pruning a same-stem run di…
qwen-code-dev-bot Aug 11, 2026
5cc7242
Merge branch 'codex/issue-8105-workflow-durable-journal' of https://g…
qwen-code-dev-bot Aug 11, 2026
c2c0eb3
fix(workflows): make artifact opens nonblocking
qqqys Aug 12, 2026
e09b429
fix(workflows): bound durable artifact writes
qqqys Aug 12, 2026
56b13a0
Merge branch 'main' into codex/issue-8105-workflow-durable-journal
wenshao Aug 12, 2026
a40ba51
fix(workflows): bound result projection size
qqqys Aug 12, 2026
f8c1278
Merge branch 'main' into codex/issue-8105-workflow-durable-journal
qqqys Aug 13, 2026
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
207 changes: 205 additions & 2 deletions packages/cli/src/ui/commands/workflowsCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import path from 'node:path';
import { workflowsCommand } from './workflowsCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { WorkflowTask, WorkflowSnapshot } from '@qwen-code/qwen-code-core';
import {
Storage,
writeWorkflowManifest,
type Config,
type WorkflowTask,
type WorkflowSnapshot,
} from '@qwen-code/qwen-code-core';

function entry(overrides: Partial<WorkflowTask> = {}): WorkflowTask {
return {
Expand Down Expand Up @@ -536,7 +542,9 @@ describe('workflowsCommand', () => {

// Write snapshot JSON files into a fresh temp dir and return a context
// whose `config.storage.getWorkflowRunsDir()` points at it. This drives
// the real `listWorkflowSnapshots` (no module mocking).
// the real `listWorkflowRunRecords` (no module mocking) — note the
// different bad-record semantics: an unparseable file surfaces as a
// visible `interrupted · not recoverable` row rather than being skipped.
Comment on lines 543 to +547

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred (test-only). The bad-record semantic the R6-4 harness comment promises; tracked with the CLI test follow-up round.

中文说明

推迟(仅测试)。R6-4 脚手架注释承诺的坏记录语义;随 CLI 测试后续轮次跟进。

async function ctxWithSnapshots(
snaps: Array<Partial<WorkflowSnapshot>>,
mode: 'interactive' | 'non_interactive' = 'interactive',
Expand All @@ -562,6 +570,51 @@ describe('workflowsCommand', () => {
} as unknown as Parameters<typeof createMockCommandContext>[0]);
}

async function ctxWithManifest(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred (test hygiene). The stale ctxWithSnapshots harness constant after the listing migration; cleaned with the CLI test follow-up.

中文说明

推迟(测试卫生)。列表迁移后遗留的 ctxWithSnapshots 脚手架常量;随 CLI 测试后续轮次清理。

run: Partial<WorkflowTask>,
journal: {
integrity: 'complete' | 'failed';
error?: string;
} = { integrity: 'complete' },
): Promise<CommandContext> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wf-manifest-cli-'));
tmpDirs.push(root);
const storage = new Storage(root, path.join(root, 'runtime'));
const config = {
storage,
getWorkflowRunRegistry: () => ({
list: listMock,
get: getMock,
pause: pauseMock,
resume: resumeMock,
}),
};
await writeWorkflowManifest(
config as unknown as Config,
entry({
runId: 'wf_deadbeef',
status: 'running',
script: 'return 1;',
...run,
}),
{
args: null,
journal: {
version: 1,
keyVersion: 'v2',
byteLength: 0,
sha256:
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
...journal,
},
},
);
return createMockCommandContext({
services: { config },
executionMode: 'interactive',
} as unknown as Parameters<typeof createMockCommandContext>[0]);
}

afterEach(async () => {
await Promise.all(
tmpDirs
Expand All @@ -585,6 +638,23 @@ describe('workflowsCommand', () => {
expect(result.content).toContain('oldrun');
});

it.runIf(process.platform !== 'win32')(
'does not render a control-character legacy filename',
async () => {
listMock.mockReturnValue([]);
const ctx = await ctxWithSnapshots([
{ runId: 'wf_bad\nCONTROL_SENTINEL' },
]);
const result = await workflowsCommand.action!(ctx, '');
expect(result).toMatchObject({
type: 'message',
messageType: 'info',
content: 'No workflow runs recorded yet.',
});
expect(JSON.stringify(result)).not.toContain('CONTROL_SENTINEL');
},
);

it('live registry entry shadows a same-runId snapshot (no duplicate row)', async () => {
listMock.mockReturnValue([
entry({
Expand Down Expand Up @@ -668,5 +738,138 @@ describe('workflowsCommand', () => {
content: 'Unknown live workflow runId: wf_ghost',
});
});

it('classifies an orphaned active manifest as interrupted and recoverable', async () => {
listMock.mockReturnValue([]);
const ctx = await ctxWithManifest({
runId: 'wf_deadbeef',
status: 'paused',
});

const result = await workflowsCommand.action!(ctx, '');
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).toContain('Workflow runs (1 total · 0 active)');
Comment on lines +749 to +751

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The recovery : recoverable arm of the detail view (detailLines' entry.canResume ? 'recoverable' : 'not recoverable') has zero test coverage — every detail-view test seeds a canResume: false manifest, and the one recoverable test asserts only the listing row. — Failure scenario: A future regression in the recovery block — e.g. gating the detail block on entry.status === 'interrupted' && !entry.canResume, flipping the ternary, or keying it off originalStatus — ships with the entire CLI suite green: all four detail tests would still render 'not recoverable' and pass. The same run's listing row still reads '· recoverable' (that arm is tested), so the two views silently contradict for the exact orphaned-paused run a user inspects. The reason-line guard is likewise untested on its canResume:true boundary.

Suggested change
const result = await workflowsCommand.action!(ctx, '');
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).toContain('Workflow runs (1 total · 0 active)');
Extend the 'classifies an orphaned active manifest as interrupted and recoverable' test to also run the detail path and assert `recovery : recoverable` and the absence of a `reason` line.
中文说明

[Suggestion] 详情视图的 recovery : recoverable 分支(detailLines 的 entry.canResume ? 'recoverable' : 'not recoverable')零测试覆盖——所有详情测试都 seed 了 canResume:false 的 manifest,唯一可恢复的测试只断言了列表行。— 失败场景:未来对 recovery 块的回归(如把详情块改为只对 !entry.canResume 生效、翻转三元、或改 key 为 originalStatus)会在整个 CLI 套件全绿下发布:四个详情测试仍渲染 'not recoverable' 并通过。同一 run 的列表行仍显示 '· recoverable',两个视图对用户正在检查的 orphaned-paused run 静默矛盾。reason 行守卫在 canResume:true 边界同样未测。

— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred (test gap, CLI detail view). The recoverable/not-recoverable arm of the detail view has no direct assertion; tracked with the CLI test follow-up.

中文说明

推迟(测试缺口,CLI 详情视图)。详情视图的 recoverable/not-recoverable 分支无直接断言;随 CLI 测试后续轮次跟进。

expect(result.content).toContain('wf_deadbeef');
expect(result.content).toContain('interrupted');
expect(result.content).toContain('· recoverable');
});

it('shows a durable recovery failure reason in detail', async () => {
getMock.mockReturnValue(undefined);
const ctx = await ctxWithManifest(
{ runId: 'wf_badc0de', status: 'running' },
{ integrity: 'failed', error: 'disk write failed' },
);

const result = await workflowsCommand.action!(ctx, 'wf_badc0de');
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).toContain('status : interrupted');
expect(result.content).toContain('recovery : not recoverable');
expect(result.content).toContain('reason : disk write failed');
});

// The reason string is content another process wrote, not a runId, so
// the legacy-filename guard never sees it. Both the listing row (which
// slices to 80 chars, keeping any ESC) and the detail view render it.
it('strips terminal control bytes from a durable recovery reason', async () => {
getMock.mockReturnValue(undefined);
const ctx = await ctxWithManifest(
{ runId: 'wf_badc0de', status: 'running' },
{
integrity: 'failed',
error: '\u001b]0;pwned\u0007disk write failed',
},
);

const listing = await workflowsCommand.action!(ctx, '');
const detail = await workflowsCommand.action!(ctx, 'wf_badc0de');
if (!listing || listing.type !== 'message') throw new Error('no result');
if (!detail || detail.type !== 'message') throw new Error('no result');

expect(listing.content).not.toContain('\u001b');
expect(detail.content).not.toContain('\u001b');
expect(detail.content).toContain('disk write failed');
});

// Same vector as the recovery-reason case, through the rest of the
// rendered family. `parseManifest` type-checks these as strings only,
// so a crafted manifest in the project's workflows dir carries ESC/OSC
// bytes all the way into both render paths.
it('strips terminal control bytes from every rendered manifest string', async () => {
getMock.mockReturnValue(undefined);
const esc = '\u001b]0;pwned\u0007';
const ctx = await ctxWithManifest({
runId: 'wf_badc0de',
status: 'failed',
meta: {
name: `${esc}evil-name`,
description: `${esc}evil-description`,
whenToUse: `${esc}evil-when`,
},
error: `${esc}boom`,
recentLogs: [`${esc}log line`],
phases: [`${esc}phase one`],
perPhaseTokens: new Map([[`${esc}phase one`, 42]]),
});

const listing = await workflowsCommand.action!(ctx, '');
const detail = await workflowsCommand.action!(ctx, 'wf_badc0de');
if (!listing || listing.type !== 'message') throw new Error('no result');
if (!detail || detail.type !== 'message') throw new Error('no result');

expect(listing.content).not.toContain('\u001b');
expect(detail.content).not.toContain('\u001b');
// Sanitizing must not swallow the content itself.
expect(detail.content).toContain('evil-name');
expect(detail.content).toContain('evil-description');
expect(detail.content).toContain('evil-when');
expect(detail.content).toContain('boom');
expect(detail.content).toContain('log line');
expect(detail.content).toContain('phase one');
// The token chip still resolves, so the map keys went through the
// same sanitizing pass as the phase list.
expect(detail.content).toContain('42t');
});

// A kept LF would let a crafted record forge extra rows and extra
// detail fields, which is why the single-line helper flattens it.
it('flattens newlines in single-line manifest fields', async () => {
getMock.mockReturnValue(undefined);
const ctx = await ctxWithManifest({
runId: 'wf_badc0de',
status: 'failed',
error: 'boom\n status : completed',
});

const detail = await workflowsCommand.action!(ctx, 'wf_badc0de');
if (!detail || detail.type !== 'message') throw new Error('no result');

const lines = detail.content.split('\n');
// The payload stays inside the error row instead of becoming a row
// of its own, so it cannot impersonate a real status field.
expect(lines.some((l) => l.startsWith(' error : boom'))).toBe(
true,
);
expect(lines).not.toContain(' status : completed');
expect(lines).toContain(' status : failed');
});

it('never routes pause or resume to an interrupted persisted run', async () => {
getMock.mockReturnValue(undefined);
const ctx = await ctxWithManifest({
runId: 'wf_deadbeef',
status: 'paused',
});

const result = await workflowsCommand.action!(ctx, 'p wf_deadbeef');
expect(result).toMatchObject({
type: 'message',
messageType: 'error',
content:
'Workflow wf_deadbeef is interrupted and has no live owner; it cannot be paused or resumed.',
});
expect(pauseMock).not.toHaveBeenCalled();
expect(resumeMock).not.toHaveBeenCalled();
});
});
});
Loading
Loading