Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
349f154
fix(flows): recover orphaned runs and add Stop button
albertoperdomo2 Aug 13, 2026
88ea307
test(flows): update recoverStaleRunningRuns assertion for NULL lease …
albertoperdomo2 Aug 14, 2026
802d913
chore(openspec): add flow-run-recovery-and-stop proposal
albertoperdomo2 Aug 28, 2026
aa6a7c3
chore(openspec): add archectl-flow-scheduler-mode proposal
albertoperdomo2 Aug 31, 2026
478f3f8
fix(archectl): write flow scheduler mode and run the flow daemon
albertoperdomo2 Aug 31, 2026
91c4aa6
test(archectl): cover flow scheduler mode and flows service in render…
albertoperdomo2 Aug 31, 2026
e4200cb
chore(openspec): mark archectl-flow-scheduler-mode tasks complete
albertoperdomo2 Aug 31, 2026
c1ad2c2
fix(flows): keep gateway tokens fresh across flow execution
albertoperdomo2 Sep 1, 2026
e01dc32
Merge branch 'main' into fix/flow-gateway-token-refresh
albertoperdomo2 Sep 2, 2026
3305de1
Merge branch 'main' into fix/flow-orphaned-runs-stop-button
albertoperdomo2 Sep 2, 2026
5c1979f
Merge branch 'main' into fix/archectl-flow-scheduler-mode
albertoperdomo2 Sep 2, 2026
7541798
fix(flows): force the between-steps token refresh
albertoperdomo2 Sep 2, 2026
285e404
fix(flows): settle in-flight steps when a run is cancelled or recovered
albertoperdomo2 Sep 2, 2026
9b237db
fix(flows): stay on the flows list after starting a run
albertoperdomo2 Sep 2, 2026
69c915e
fix(opencode): never dispose the instance under an active run
albertoperdomo2 Sep 2, 2026
27cb81a
Merge remote-tracking branch 'origin/fix/flow-gateway-token-refresh' …
albertoperdomo2 Sep 2, 2026
0e907da
Merge remote-tracking branch 'origin/fix/archectl-flow-scheduler-mode…
albertoperdomo2 Sep 2, 2026
fa42450
feat(archectl): run the reaper daemon in the one-click stack
albertoperdomo2 Sep 2, 2026
6db50d8
fix(flows): close the dispose stale-state trap and settle unconfirmed…
albertoperdomo2 Sep 2, 2026
65937a5
fix(tests): repair CI failures from the dispose-guard and no-output c…
albertoperdomo2 Sep 2, 2026
b93241d
fix(opencode): wait for instance health after a provider-sync dispose
albertoperdomo2 Sep 3, 2026
14145ed
docs(openspec): specify run-list in-place refresh and dispose health …
albertoperdomo2 Sep 3, 2026
b141e6c
convoy(fixes): Fix flow recovery and governance regressions
Inakitajes Sep 15, 2026
eca3794
fix(ci): stop secret scanner flagging its own fixtures
Inakitajes Sep 15, 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
8 changes: 7 additions & 1 deletion .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ jobs:
- name: Check changed paths
id: changes
run: |
changed=$(git diff --name-only origin/main...HEAD 2>/dev/null || echo "")
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
echo "web=true" >> "$GITHUB_OUTPUT"
echo "desktop=true" >> "$GITHUB_OUTPUT"
exit 0
fi

changed=$(git diff --name-only origin/main...HEAD)
echo "web=$(echo "$changed" | grep -qE '^(apps/web/|scripts/)' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "desktop=$(echo "$changed" | grep -qE '^apps/desktop/' && echo true || echo false)" >> "$GITHUB_OUTPUT"

Expand Down
30 changes: 17 additions & 13 deletions .github/workflows/pr-governance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,6 @@ jobs:
check_package() {
local pkg="$1"

if ! jq empty "$pkg" 2>/dev/null; then
echo "::warning file=${pkg}::Malformed package.json — skipped"
return 1
fi

local unpinned
unpinned=$(jq -r '
[(.dependencies // {}), (.devDependencies // {})]
Expand Down Expand Up @@ -103,6 +98,11 @@ jobs:
-not -path '*/.pnpm/*' \
| sort); do

if ! jq empty "$pkg" 2>/dev/null; then
echo "::warning file=${pkg}::Malformed package.json — skipped"
continue
fi

deps=$(jq -r '(.dependencies // {} | length) + (.devDependencies // {} | length)' "$pkg" 2>/dev/null)
if [[ "$deps" == "0" ]] || [[ -z "$deps" ]]; then
continue
Expand Down Expand Up @@ -136,8 +136,8 @@ jobs:
- name: Get changed files
id: changes
run: |
changed=$(git diff --name-only --diff-filter=ACR origin/main...HEAD 2>/dev/null \
|| git diff --name-only --diff-filter=ACR HEAD~1 2>/dev/null \
changed=$(git diff --name-only --diff-filter=ACMR origin/main...HEAD 2>/dev/null \
|| git diff --name-only --diff-filter=ACMR HEAD~1 2>/dev/null \
|| echo "")
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 2>/dev/null | base64)
echo "files<<${EOF_MARKER}" >> "$GITHUB_OUTPUT"
Expand All @@ -146,6 +146,8 @@ jobs:

- name: Check for sensitive files
if: steps.changes.outputs.files != ''
env:
CHANGED_FILES: ${{ steps.changes.outputs.files }}
run: |
sensitive_patterns=(
'\.env$'
Expand All @@ -170,7 +172,7 @@ jobs:
)

found=0
changed_files="${{ steps.changes.outputs.files }}"
changed_files="$CHANGED_FILES"

for pattern in "${sensitive_patterns[@]}"; do
matches=$(echo "$changed_files" | grep -iE "$pattern" || true)
Expand All @@ -192,8 +194,10 @@ jobs:

- name: Scan for hardcoded secrets
if: steps.changes.outputs.files != ''
env:
CHANGED_FILES: ${{ steps.changes.outputs.files }}
run: |
changed_files="${{ steps.changes.outputs.files }}"
changed_files="$CHANGED_FILES"

# Only scan text files
scannable=$(echo "$changed_files" | grep -vE '\.(png|jpg|jpeg|gif|ico|svg|woff2?|ttf|eot|mp[34]|webm|webp|zip|tar|gz|lock)$' || true)
Expand Down Expand Up @@ -226,10 +230,10 @@ jobs:
'glpat-[a-zA-Z0-9_-]{20,}'
'xox[bsapr]-[a-zA-Z0-9-]+'
'BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY'
'password\s*[:=]\s*["\x27][^"\x27]{8,}'
'secret\s*[:=]\s*["\x27][^"\x27]{8,}'
'api[_-]?key\s*[:=]\s*["\x27][^"\x27]{8,}'
'access[_-]?token\s*[:=]\s*["\x27][^"\x27]{8,}'
"password\\s*[:=]\\s*[\"'][^\"']{8,}"
"secret\\s*[:=]\\s*[\"'][^\"']{8,}"
"api[_-]?key\\s*[:=]\\s*[\"'][^\"']{8,}"
"access[_-]?token\\s*[:=]\\s*[\"'][^\"']{8,}"
)

for pattern in "${secret_patterns[@]}"; do
Expand Down
75 changes: 75 additions & 0 deletions .github/workflows/pr-workflows.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import test from 'node:test'

const governance = await readFile(new URL('./pr-governance.yml', import.meta.url), 'utf8')
const checks = await readFile(new URL('./pr-checks.yml', import.meta.url), 'utf8')

test('SC-2: security scanning matches single-quoted secret assignments', () => {
const start = governance.indexOf(' secret_patterns=(')
const end = governance.indexOf(' )', start) + ' )'.length
const secretPatterns = governance.slice(start, end)
const script = `${secretPatterns}
for pattern in "\${secret_patterns[@]}"; do
if printf '%s\\n' "$1" | grep -iE "$pattern" >/dev/null; then
exit 0
fi
done
exit 1`

// Assemble the sample at runtime: the literal assignment would itself match
// the secret patterns this scan is checking for.
const fakeValue = 'hunter2secret'
for (const quote of ["'", '"']) {
const assignment = `password = ${quote}${fakeValue}${quote}`
const result = spawnSync('bash', ['-c', script, '--', assignment])
assert.equal(result.status, 0, `secret scanner must match ${assignment}`)
}
})

test('SC-2: sensitive-file scanning includes modified paths', () => {
assert.match(governance, /--diff-filter=ACMR/)
})

test('SC-4: PR-controlled filenames are passed to security scan steps through the environment', () => {
assert.doesNotMatch(governance, /changed_files="\$\{\{ steps\.changes\.outputs\.files \}\}"/)
assert.equal(
governance.match(/CHANGED_FILES: \$\{\{ steps\.changes\.outputs\.files \}\}/g)?.length,
2,
)
assert.match(governance, /"\$CHANGED_FILES"/)
})

test('SC-6: manual PR-check runs enable both application suites and do not hide diff failures', () => {
assert.match(checks, /\$GITHUB_EVENT_NAME" == "workflow_dispatch"/)
assert.doesNotMatch(checks, /git diff --name-only origin\/main\.\.\.HEAD 2>\/dev\/null \|\| echo ""/)
})

test('SC-10: malformed manifests warn without failing the pinned-version check', () => {
const marker = ' - name: Check dependency version pinning\n run: |\n'
const start = governance.indexOf(marker) + marker.length
const end = governance.indexOf('\n\n security-scan:', start)
const script = governance.slice(start, end).replace(/^ /gm, '')
const fixtureDirectory = mkdtempSync(join(process.cwd(), '.pr-workflows-test-'))

try {
writeFileSync(join(fixtureDirectory, 'package.json'), '{"dependencies":')

const result = spawnSync('bash', ['-e', '-o', 'pipefail', '-c', script], {
cwd: fixtureDirectory,
encoding: 'utf8',
})

assert.equal(
result.status,
0,
`expected malformed manifests to be non-fatal; stdout: ${result.stdout || '<empty>'}; stderr: ${result.stderr || '<empty>'}`,
)
assert.match(result.stdout, /::warning file=\.\/package\.json::Malformed package\.json — skipped/)
} finally {
rmSync(fixtureDirectory, { force: true, recursive: true })
}
})
50 changes: 48 additions & 2 deletions apps/web/src/components/flows/__tests__/flow-run-history.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
/** @vitest-environment jsdom */
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { FlowRunHistory } from '@/components/flows/flow-run-history'
import type { FlowDetail } from '@/lib/flows/types'

const clientMocks = vi.hoisted(() => ({
cancelFlowRunRequest: vi.fn(),
}))

vi.mock('@/lib/flows/client', () => ({
cancelFlowRunRequest: clientMocks.cancelFlowRunRequest,
}))

const flow: FlowDetail = {
createdAt: '2026-05-12T10:00:00.000Z',
cronExpression: null,
Expand Down Expand Up @@ -59,6 +67,10 @@ const flow: FlowDetail = {
}

describe('FlowRunHistory', () => {
beforeEach(() => {
vi.clearAllMocks()
})

afterEach(() => cleanup())

it('renders run history and session link', () => {
Expand Down Expand Up @@ -90,4 +102,38 @@ describe('FlowRunHistory', () => {

expect(screen.getByText('No runs recorded yet.')).toBeTruthy()
})

it('surfaces a rejected card Stop request', async () => {
clientMocks.cancelFlowRunRequest.mockResolvedValue({ ok: false, error: 'forbidden' })
render(<FlowRunHistory flow={{
...flow,
runs: [{
...flow.runs[0]!,
executionUser: { slug: 'alice' },
finishedAt: null,
status: 'running',
}],
}} slug="alice" />)

fireEvent.click(screen.getByRole('button', { name: 'Stop' }))

expect(await screen.findByText('forbidden')).toBeTruthy()
})

it('surfaces a card Stop network failure', async () => {
clientMocks.cancelFlowRunRequest.mockRejectedValue(new Error('offline'))
render(<FlowRunHistory flow={{
...flow,
runs: [{
...flow.runs[0]!,
executionUser: { slug: 'alice' },
finishedAt: null,
status: 'running',
}],
}} slug="alice" />)

fireEvent.click(screen.getByRole('button', { name: 'Stop' }))

expect(await screen.findByText('Network error. Try again.')).toBeTruthy()
})
})
Loading
Loading