-
Notifications
You must be signed in to change notification settings - Fork 0
ci: gate pull requests on tests and typecheck #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
fffd383
ci: gate pull requests on tests and typecheck
miyaontherelay 475e25e
fix: await worker log cleanup
miyaontherelay 68ebb3d
ci: do not persist the checkout token in the runner
khaliqgant 4050ece
fix(core): do not drop agent output while re-keying a renamed agent
khaliqgant File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| name: CI | ||
|
|
||
| on: | ||
| pull_request: | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| test: | ||
| name: Test and typecheck | ||
| runs-on: ubuntu-latest | ||
|
|
||
| steps: | ||
| - name: Checkout code | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| # This job builds and runs code from the pull request head. Leaving | ||
| # the checkout token in the runner's git config would expose it to | ||
| # every npm lifecycle script and test subprocess that follows. | ||
| persist-credentials: false | ||
|
|
||
| - name: Setup Node.js | ||
| uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: '22' | ||
| cache: npm | ||
|
|
||
| - name: Install dependencies | ||
| run: npm ci | ||
|
|
||
| # `npm run typecheck` already runs `build:primitives` and builds core, and | ||
| # `npm test` needs the primitives built. Running typecheck first means one | ||
| # build serves both gates instead of building the primitives twice. | ||
| - name: Typecheck | ||
| run: npm run typecheck | ||
|
|
||
| # Always report the test result, so a typecheck failure does not hide it. | ||
| - name: Test | ||
| if: ${{ !cancelled() }} | ||
| run: npm test |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| /** | ||
| * The broker can assign an agent a different name than the one we asked for. | ||
| * Re-keying the PTY maps to that name is one critical section with an | ||
| * unavoidable `await` in it — the old log stream must be closed before its file | ||
| * can be renamed — and a `worker_stream` for the old name can arrive inside | ||
| * that window. These tests hold that window open deliberately and assert that | ||
| * nothing addressed to the old name is lost. | ||
| */ | ||
| import { createWriteStream, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; | ||
| import os from 'node:os'; | ||
| import path from 'node:path'; | ||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { WorkflowRunner, type WorkflowDb } from '../runner.js'; | ||
| import type { WorkflowRunRow, WorkflowStep, WorkflowStepRow } from '../types.js'; | ||
|
|
||
| function makeDb(): WorkflowDb { | ||
| const runs = new Map<string, WorkflowRunRow>(); | ||
| const steps = new Map<string, WorkflowStepRow>(); | ||
| return { | ||
| insertRun: vi.fn(async (run) => runs.set(run.id, { ...run })), | ||
| updateRun: vi.fn(async (id, patch) => { | ||
| const run = runs.get(id); | ||
| if (run) runs.set(id, { ...run, ...patch }); | ||
| }), | ||
| getRun: vi.fn(async (id) => { | ||
| const run = runs.get(id); | ||
| return run ? { ...run } : null; | ||
| }), | ||
| insertStep: vi.fn(async (step) => steps.set(step.id, { ...step })), | ||
| updateStep: vi.fn(async (id, patch) => { | ||
| const step = steps.get(id); | ||
| if (step) steps.set(id, { ...step, ...patch }); | ||
| }), | ||
| getStepsByRunId: vi.fn(async (runId) => | ||
| [...steps.values()].filter((s) => s.runId === runId).map((s) => ({ ...s })) | ||
| ), | ||
| }; | ||
| } | ||
|
|
||
| const step: WorkflowStep = { name: 'worker', type: 'agent', agent: 'a', task: 't' }; | ||
|
|
||
| describe('PTY re-key when the broker renames an agent', () => { | ||
| const tempDirs: string[] = []; | ||
| afterEach(() => { | ||
| for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| function setup() { | ||
| const logsDir = mkdtempSync(path.join(os.tmpdir(), 'relayflows-pty-rekey-')); | ||
| tempDirs.push(logsDir); | ||
| const runner = new WorkflowRunner({ db: makeDb(), cwd: logsDir, workspaceId: 'ws-test' }) as any; | ||
|
|
||
| const oldLogPath = path.join(logsDir, 'old-name.log'); | ||
| writeFileSync(oldLogPath, 'before\n'); | ||
| runner.ptyOutputBuffers.set('old-name', ['before\n']); | ||
| runner.ptyLogStreams.set('old-name', createWriteStream(oldLogPath, { flags: 'a' })); | ||
| runner.ptyListeners.set('old-name', () => { | ||
| throw new Error('the pre-rekey listener must be replaced, not invoked'); | ||
| }); | ||
|
|
||
| return { runner, logsDir }; | ||
| } | ||
|
|
||
| async function flush(runner: any, name: string) { | ||
| await new Promise<void>((resolve) => runner.ptyLogStreams.get(name)?.end(resolve)); | ||
| } | ||
|
|
||
| it('captures a chunk that arrives for the old name during the await window', async () => { | ||
| const { runner, logsDir } = setup(); | ||
| const seen: string[] = []; | ||
|
|
||
| // Do NOT await: this leaves the critical section suspended on the log | ||
| // stream close, which is exactly the window the race lives in. | ||
| const rekey = runner.rekeyPtyStreams({ | ||
| oldName: 'old-name', | ||
| newName: 'new-name', | ||
| logsDir, | ||
| step, | ||
| humanAssistanceConfig: undefined, | ||
| onChunk: (info: { agentName: string; chunk: string }) => seen.push(`${info.agentName}:${info.chunk}`), | ||
| }); | ||
|
|
||
| const inFlight = runner.ptyListeners.get('old-name'); | ||
| expect(inFlight, 'the old name must still route somewhere mid-swap').toBeTypeOf('function'); | ||
| inFlight('during\n'); | ||
|
|
||
| await rekey; | ||
| await flush(runner, 'new-name'); | ||
|
|
||
| // The chunk reached the buffer, under the new name. | ||
| expect(runner.ptyOutputBuffers.get('new-name')).toContain('during\n'); | ||
| expect(runner.ptyOutputBuffers.has('old-name')).toBe(false); | ||
| // It was parked while no stream existed, then written to the renamed file. | ||
| expect(readFileSync(path.join(logsDir, 'new-name.log'), 'utf8')).toBe('before\nduring\n'); | ||
| // And it was reported to the caller as the new agent. | ||
| expect(seen).toEqual(['new-name:during\n']); | ||
| // The old key is retired only once the swap is complete. | ||
| expect(runner.ptyListeners.has('old-name')).toBe(false); | ||
| expect(runner.ptyListeners.has('new-name')).toBe(true); | ||
| }); | ||
|
|
||
| it('keeps the earlier buffer contents and routes chunks after the swap', async () => { | ||
| const { runner, logsDir } = setup(); | ||
|
|
||
| await runner.rekeyPtyStreams({ | ||
| oldName: 'old-name', | ||
| newName: 'new-name', | ||
| logsDir, | ||
| step, | ||
| humanAssistanceConfig: undefined, | ||
| }); | ||
|
|
||
| runner.ptyListeners.get('new-name')('after\n'); | ||
| await flush(runner, 'new-name'); | ||
|
|
||
| expect(runner.ptyOutputBuffers.get('new-name')).toEqual(['before\n', 'after\n']); | ||
| expect(readFileSync(path.join(logsDir, 'new-name.log'), 'utf8')).toBe('before\nafter\n'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: AgentWorkforce/relayflows
Length of output: 253
🏁 Script executed:
Repository: AgentWorkforce/relayflows
Length of output: 14589
🌐 Web query:
Node.js fs WriteStream end callback close event Windows rename open file behavior libuv💡 Result:
In Node.js, managing file operations on Windows requires careful attention to stream life cycles and platform-specific I/O behavior [1][2]. Node.js fs.WriteStream Event Sequence When using fs.createWriteStream, the 'finish' event signifies that all data has been flushed to the underlying system [3][4]. However, the file descriptor remains open until the 'close' event is emitted [5][6]. If your goal is to perform a subsequent operation—such as renaming—on the file, you must ensure the stream is fully closed to avoid EPERM (operation not permitted) errors [1]. You should wait for the 'close' event (or use the callback provided to the stream's end method, if available) to guarantee the file handle has been released by the OS [6][1]. Relying on the 'finish' event alone is insufficient because it does not guarantee that the file descriptor has been closed [5][4]. Windows Rename and libuv Behavior On Windows, file renaming is not an atomic operation in the same way it is on POSIX systems [2]. Windows enforces mandatory file locking, meaning any process holding a file handle—including antivirus software, indexing services, or your own unclosed Node.js stream—will block a rename operation [7][1][2]. When fs.rename fails due to these locks, it typically throws an EPERM or EACCES error [8][7][2]. Because libuv does not implement automatic retries for rename operations (previous attempts to add this were reverted to avoid unexpected behavior or side effects), this limitation is considered standard for Windows-based Node.js development [8][7][9]. Recommended Pattern To handle this reliably on Windows: 1. Ensure your WriteStream has fully closed by listening for the 'close' event before triggering the rename [1]. 2. If renaming still fails due to intermittent locks (e.g., from antivirus or background indexers), implement a retry mechanism with exponential backoff in user-land code [1][2]. Modules like graceful-fs are commonly used to provide these platform-specific workarounds [8][2].
Citations:
Wait for
closebefore renaming the log file.closeWriteStreamresolves from thestream.end()callback, which can run whilestream.closedisfalse.rekeyPtyStreamsthen callsrenameSync; on Windows, the open handle can causerenameSyncto throw. The catch ignores that error, so the new log can omit output written before re-keying.Use the
closeevent for successful completion and retain the error path.🤖 Prompt for AI Agents