diff --git a/.github/actions/install-cargo-component/action.yml b/.github/actions/install-cargo-component/action.yml new file mode 100644 index 0000000000..8114e0888c --- /dev/null +++ b/.github/actions/install-cargo-component/action.yml @@ -0,0 +1,18 @@ +name: Install cargo-component +description: Install cargo-component using a precompiled binary (taiki-e/install-action). + +# Replaces ad-hoc `cargo install cargo-component --locked || true` calls. The +# precompiled-binary path takes a few seconds vs. several minutes for a source +# install, and we no longer swallow install failures. +# +# The version is pinned for build reproducibility — same rationale as +# `--locked` for source installs. Bump alongside the cargo-component dependency +# in dependabot's `cargo-component*` group (see .github/dependabot.yml). + +runs: + using: composite + steps: + - name: Install cargo-component + uses: taiki-e/install-action@62b0f2dec647a8e604c6a0fda0e38530180dce20 # v2 + with: + tool: cargo-component@0.21.1 diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index 2dcfedecb0..e2aa861298 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -12,9 +12,70 @@ on: permissions: contents: read +concurrency: + group: code-style-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: + # ── Skip when only docs/config changed on a PR ── + changes: + name: Detect code changes + runs-on: ubuntu-latest + outputs: + has_code: ${{ steps.non_pr.outputs.has_code || steps.pr_check.outputs.has_code }} + steps: + - id: non_pr + if: github.event_name != 'pull_request' + run: echo "has_code=true" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + if: github.event_name == 'pull_request' + with: + fetch-depth: 0 + persist-credentials: false + - id: pr_check + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + CODE_CHANGES=$(git diff --name-only "$BASE_SHA"...HEAD -- \ + 'src/' 'crates/' 'channels-src/' 'tools-src/' 'tests/' 'migrations/' \ + 'Cargo.toml' 'Cargo.lock' 'Dockerfile' 'build.rs' \ + | head -1) + if [ -n "$CODE_CHANGES" ]; then + echo "has_code=true" >> "$GITHUB_OUTPUT" + else + echo "has_code=false" >> "$GITHUB_OUTPUT" + echo "No code changes — style checks will be skipped" + fi + + # ── Dynamic matrix: SLIM (1 leg) for non-main PRs; FULL (3 legs) for PRs + # to main and pushes to long-lived branches. + clippy-matrix: + name: Configure clippy matrix + needs: changes + if: needs.changes.outputs.has_code == 'true' + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set.outputs.matrix }} + steps: + - id: set + run: | + FULL='[{"name":"all-features","flags":"--all-features"},{"name":"default","flags":""},{"name":"libsql-only","flags":"--no-default-features --features libsql"}]' + SLIM='[{"name":"all-features","flags":"--all-features"}]' + + # Full matrix on push (cache-warming + verification across configs) + # and on PRs targeting main (final promotion gate). Other PRs use + # SLIM since lint findings are almost never feature-gated. + if [ "${{ github.event_name }}" = "push" ] || [ "${{ github.base_ref }}" = "main" ]; then + echo "matrix=${FULL}" >> "$GITHUB_OUTPUT" + else + echo "matrix=${SLIM}" >> "$GITHUB_OUTPUT" + fi + format: name: Formatting + needs: changes + if: needs.changes.outputs.has_code == 'true' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -30,6 +91,8 @@ jobs: gateway-js-syntax: name: Gateway JS syntax + needs: changes + if: needs.changes.outputs.has_code == 'true' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -45,6 +108,8 @@ jobs: deny-check: name: cargo-deny + needs: changes + if: needs.changes.outputs.has_code == 'true' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -56,22 +121,13 @@ jobs: clippy: name: Clippy (${{ matrix.name }}) - # Push events only run the all-features leg. That leg builds a superset - # of artifacts, so it deterministically wins the shared cache slot on - # main/staging refreshes and PR legs always restore from a useful - # starting point. PRs still run all three to enforce lint coverage. - if: github.event_name == 'pull_request' || matrix.name == 'all-features' + needs: [changes, clippy-matrix] + if: needs.changes.outputs.has_code == 'true' runs-on: ubuntu-latest strategy: fail-fast: false matrix: - include: - - name: all-features - flags: "--all-features" - - name: default - flags: "" - - name: libsql-only - flags: "--no-default-features --features libsql" + include: ${{ fromJSON(needs.clippy-matrix.outputs.matrix) }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -83,29 +139,33 @@ jobs: components: clippy - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: - # Share a single cache across the clippy matrix. `target/` built for - # --all-features is a superset of the other two legs, so restoring - # from whichever variant saved last is still faster than a cold build - # and avoids storing three near-duplicate copies in the GHA cache. + # Single shared slot across the clippy matrix. The all-features leg + # builds a superset of the other configs; subset legs (push only) + # restore from it. Only all-features writes the slot, and only on + # main/staging pushes — preventing a subset leg from winning the + # cache race and pinning the slot to a partial build. shared-key: clippy - save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') }} + save-if: ${{ matrix.name == 'all-features' && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') }} + # `--benches` is intentionally omitted: lints rarely diverge in bench code, + # and pulling in `criterion` adds ~30s+ of dependency compilation per run. + # `bench-compile` in test.yml provides the type-check signal for benches. - name: Check lints - run: cargo clippy --all --benches --tests --examples ${{ matrix.flags }} -- -D warnings + run: cargo clippy --all --tests --examples ${{ matrix.flags }} -- -D warnings clippy-windows: name: Clippy Windows (${{ matrix.name }}) - if: github.base_ref == 'main' + needs: [changes, clippy-matrix] + # PR-to-main runs the full Windows matrix; pushes to long-lived branches + # run it for cache warming. Other PRs skip Windows lint — windows-build + # in test.yml provides the Windows compile signal. + if: > + needs.changes.outputs.has_code == 'true' && + (github.event_name == 'push' || github.base_ref == 'main') runs-on: windows-latest strategy: fail-fast: false matrix: - include: - - name: all-features - flags: "--all-features" - - name: default - flags: "" - - name: libsql-only - flags: "--no-default-features --features libsql" + include: ${{ fromJSON(needs.clippy-matrix.outputs.matrix) }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -117,19 +177,21 @@ jobs: components: clippy - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: - # Share the per-variant Windows target cache with test.yml's - # windows-build job. That job runs on `push` to main (where save-if - # allows writes), so clippy-windows — which only runs on main PRs — - # can restore from a warm cache instead of cold-building every time. + # Per-variant Windows cache, shared with test.yml's windows-build + # job. windows-build runs on push (where save-if allows writes), so + # clippy-windows restores from a warm cache on PRs. key: windows-${{ matrix.name }} save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') }} + # `--benches` omitted; see comment on the Linux clippy job above. - name: Check lints - run: cargo clippy --all --benches --tests --examples ${{ matrix.flags }} -- -D warnings + run: cargo clippy --all --tests --examples ${{ matrix.flags }} -- -D warnings no-panics: name: No panics in production code - # Compares the PR's changes against the PR base; not meaningful on push. - if: github.event_name == 'pull_request' + needs: changes + # Diff is computed against the PR base SHA, which is unavailable on push. + # Gate to PRs only; the roll-up tolerates a skipped result on push. + if: github.event_name == 'pull_request' && needs.changes.outputs.has_code == 'true' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -150,20 +212,46 @@ jobs: name: Code Style (fmt + gateway-js-syntax + clippy + deny) runs-on: ubuntu-latest if: always() - needs: [format, gateway-js-syntax, clippy, clippy-windows, deny-check, no-panics] + needs: + - changes + - clippy-matrix + - format + - gateway-js-syntax + - clippy + - clippy-windows + - deny-check + - no-panics steps: - run: | - if [[ "${{ needs.format.result }}" != "success" || "${{ needs.gateway-js-syntax.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.deny-check.result }}" != "success" ]]; then - echo "One or more jobs failed" - exit 1 - fi - # no-panics only runs on pull_request events, so skipped is acceptable on push but failure is not - if [[ "${{ needs.no-panics.result }}" != "success" && "${{ needs.no-panics.result }}" != "skipped" ]]; then - echo "no-panics failed: ${{ needs.no-panics.result }}" - exit 1 - fi - # clippy-windows only runs on main PRs, so skipped is acceptable but failure is not - if [[ "${{ needs.clippy-windows.result }}" != "success" && "${{ needs.clippy-windows.result }}" != "skipped" ]]; then - echo "Windows clippy failed: ${{ needs.clippy-windows.result }}" - exit 1 + # Docs-only PRs intentionally skip every gated job — that's a pass. + if [[ "${{ needs.changes.outputs.has_code }}" == "false" ]]; then + echo "No code changes — style checks skipped correctly" + exit 0 fi + + # Always-required jobs. + for job_result in \ + "format=${{ needs.format.result }}" \ + "gateway-js-syntax=${{ needs.gateway-js-syntax.result }}" \ + "clippy=${{ needs.clippy.result }}" \ + "deny-check=${{ needs.deny-check.result }}"; do + name="${job_result%%=*}" + result="${job_result##*=}" + if [[ "$result" != "success" ]]; then + echo "$name failed: $result" + exit 1 + fi + done + + # Conditional jobs: must succeed when run, may be skipped on + # events where their `if:` filter excludes them. + for job_result in \ + "no-panics=${{ needs.no-panics.result }}" \ + "clippy-windows=${{ needs.clippy-windows.result }}"; do + name="${job_result%%=*}" + result="${job_result##*=}" + if [[ "$result" != "success" && "$result" != "skipped" ]]; then + echo "$name failed: $result" + exit 1 + fi + done diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 074433d232..faaa3d99fc 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -86,10 +86,7 @@ jobs: uses: taiki-e/install-action@62b0f2dec647a8e604c6a0fda0e38530180dce20 # cargo-llvm-cov - name: Install cargo-component - run: | - if ! command -v cargo-component >/dev/null 2>&1; then - cargo install cargo-component --locked - fi + uses: ./.github/actions/install-cargo-component - name: Build WASM channels (for integration tests) run: ./scripts/build-wasm-extensions.sh --channels @@ -150,10 +147,7 @@ jobs: uses: taiki-e/install-action@62b0f2dec647a8e604c6a0fda0e38530180dce20 # cargo-llvm-cov - name: Install cargo-component - run: | - if ! command -v cargo-component >/dev/null 2>&1; then - cargo install cargo-component --locked - fi + uses: ./.github/actions/install-cargo-component - name: Build WASM channels run: ./scripts/build-wasm-extensions.sh --channels diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9ed4df9cad..53932104cc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -19,6 +19,10 @@ on: permissions: contents: read +concurrency: + group: e2e-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: # ── Step 1: compile once ────────────────────────────────────────────────── build: diff --git a/.github/workflows/pr-label-classify.yml b/.github/workflows/pr-label-classify.yml index 7d0ee97a9a..6de7ea34bf 100644 --- a/.github/workflows/pr-label-classify.yml +++ b/.github/workflows/pr-label-classify.yml @@ -9,6 +9,10 @@ permissions: pull-requests: write issues: read # needed for search/issues API (contributor count) +concurrency: + group: pr-classify-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: classify: runs-on: ubuntu-latest diff --git a/.github/workflows/pr-label-scope.yml b/.github/workflows/pr-label-scope.yml index b8a282472b..e886cf7bbd 100644 --- a/.github/workflows/pr-label-scope.yml +++ b/.github/workflows/pr-label-scope.yml @@ -9,6 +9,10 @@ permissions: issues: write pull-requests: write +concurrency: + group: pr-scope-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: scope: runs-on: ubuntu-latest diff --git a/.github/workflows/regression-test-check.yml b/.github/workflows/regression-test-check.yml index d06301b378..9de538447f 100644 --- a/.github/workflows/regression-test-check.yml +++ b/.github/workflows/regression-test-check.yml @@ -6,6 +6,10 @@ on: permissions: contents: read +concurrency: + group: regression-check-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: regression-test: name: Regression test enforcement diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d23449e00..2021738bbe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -279,9 +279,9 @@ jobs: persist-credentials: false submodules: recursive - name: Install Rust toolchain + wasm target - run: | - rustup target add wasm32-wasip2 - cargo install cargo-component --locked || true + run: rustup target add wasm32-wasip2 + - name: Install cargo-component + uses: ./.github/actions/install-cargo-component - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: wasm-extensions diff --git a/.github/workflows/staging-ci.yml b/.github/workflows/staging-ci.yml index 5b8cc1abfe..ebb58d73c5 100644 --- a/.github/workflows/staging-ci.yml +++ b/.github/workflows/staging-ci.yml @@ -2,7 +2,7 @@ name: Staging CI (Batched) on: schedule: - - cron: "0 * * * *" # Every 60 minutes + - cron: "*/10 * * * *" # Every 10 minutes (down from 60) workflow_dispatch: inputs: force: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 388412fc0d..cb64df89d4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,24 +16,73 @@ on: permissions: contents: read +concurrency: + group: test-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: + # ── Skip expensive jobs when only docs/config changed on a PR ── + changes: + name: Detect code changes + runs-on: ubuntu-latest + outputs: + has_code: ${{ steps.non_pr.outputs.has_code || steps.pr_check.outputs.has_code }} + steps: + - id: non_pr + if: github.event_name != 'pull_request' + run: echo "has_code=true" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + if: github.event_name == 'pull_request' + with: + fetch-depth: 0 + persist-credentials: false + - id: pr_check + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + CODE_CHANGES=$(git diff --name-only "$BASE_SHA"...HEAD -- \ + 'src/' 'crates/' 'channels-src/' 'tools-src/' 'tests/' 'migrations/' \ + 'Cargo.toml' 'Cargo.lock' 'Dockerfile' 'build.rs' \ + | head -1) + if [ -n "$CODE_CHANGES" ]; then + echo "has_code=true" >> "$GITHUB_OUTPUT" + else + echo "has_code=false" >> "$GITHUB_OUTPUT" + echo "No code changes detected — expensive jobs will be skipped" + fi + + # ── Dynamic matrix: PRs run only all-features; full matrix on staging/push ── + matrix-config: + name: Configure matrix + runs-on: ubuntu-latest + outputs: + test_matrix: ${{ steps.set.outputs.test_matrix }} + windows_matrix: ${{ steps.set.outputs.windows_matrix }} + steps: + - id: set + run: | + FULL='[{"name":"all-features","flags":"--no-default-features --features postgres,libsql,html-to-markdown,bedrock,import"},{"name":"default","flags":""},{"name":"libsql-only","flags":"--no-default-features --features libsql"}]' + SLIM='[{"name":"all-features","flags":"--no-default-features --features postgres,libsql,html-to-markdown,bedrock,import"}]' + + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "test_matrix=${SLIM}" >> "$GITHUB_OUTPUT" + echo "windows_matrix=${SLIM}" >> "$GITHUB_OUTPUT" + else + echo "test_matrix=${FULL}" >> "$GITHUB_OUTPUT" + echo "windows_matrix=${FULL}" >> "$GITHUB_OUTPUT" + fi + tests: name: Tests (${{ matrix.name }}) + needs: [changes, matrix-config] + if: needs.changes.outputs.has_code == 'true' runs-on: ubuntu-latest timeout-minutes: 45 strategy: fail-fast: false matrix: - include: - - name: all-features - # Keep product feature coverage broad without pulling in the - # test-only `integration` feature, which is exercised separately - # in the heavy integration job below. - flags: "--no-default-features --features postgres,libsql,html-to-markdown,bedrock,import" - - name: default - flags: "" - - name: libsql-only - flags: "--no-default-features --features libsql" + include: ${{ fromJSON(needs.matrix-config.outputs.test_matrix) }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -49,7 +98,7 @@ jobs: key: ${{ matrix.name }} save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') }} - name: Install cargo-component - run: cargo install cargo-component --locked || true + uses: ./.github/actions/install-cargo-component - name: Build WASM channels (for integration tests) run: ./scripts/build-wasm-extensions.sh --channels - name: Run Tests @@ -59,6 +108,8 @@ jobs: heavy-integration-tests: name: Heavy Integration Tests + needs: changes + if: needs.changes.outputs.has_code == 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -111,6 +162,7 @@ jobs: windows-build: name: Windows Build (${{ matrix.name }}) + needs: matrix-config if: > github.event_name != 'pull_request' || github.base_ref != 'staging' @@ -118,13 +170,7 @@ jobs: strategy: fail-fast: false matrix: - include: - - name: all-features - flags: "--no-default-features --features postgres,libsql,html-to-markdown,bedrock,import" - - name: default - flags: "" - - name: libsql-only - flags: "--no-default-features --features libsql" + include: ${{ fromJSON(needs.matrix-config.outputs.windows_matrix) }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -162,7 +208,7 @@ jobs: key: wasm-extensions save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') }} - name: Install cargo-component - run: cargo install cargo-component --locked || true + uses: ./.github/actions/install-cargo-component - name: Build all WASM extensions against current WIT run: ./scripts/build-wasm-extensions.sh - name: Instantiation test (host linker compatibility) @@ -172,6 +218,8 @@ jobs: bench-compile: name: Benchmark Compilation + needs: changes + if: needs.changes.outputs.has_code == 'true' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -224,9 +272,15 @@ jobs: name: Run Tests runs-on: ubuntu-latest if: always() - needs: [tests, heavy-integration-tests, telegram-tests, wasm-wit-compat, docker-build, windows-build, version-check, bench-compile] + needs: [changes, matrix-config, tests, heavy-integration-tests, telegram-tests, wasm-wit-compat, docker-build, windows-build, version-check, bench-compile] steps: - run: | + # If no code changes on a PR, expensive jobs were correctly skipped + if [[ "${{ needs.changes.outputs.has_code }}" == "false" ]]; then + echo "No code changes — all tests skipped correctly" + exit 0 + fi + # Unit tests must always pass if [[ "${{ needs.tests.result }}" != "success" ]]; then echo "Unit tests failed" diff --git a/crates/ironclaw_gateway/static/i18n/ko.js b/crates/ironclaw_gateway/static/i18n/ko.js index 86196c68b5..67472aa103 100644 --- a/crates/ironclaw_gateway/static/i18n/ko.js +++ b/crates/ironclaw_gateway/static/i18n/ko.js @@ -39,6 +39,7 @@ I18n.register('ko', { 'tab.chat': '채팅', 'tab.memory': '기억', 'tab.jobs': '작업', + 'tab.projects': '프로젝트', 'tab.missions': '미션', 'tab.routines': '루틴', 'tab.settings': '설정', diff --git a/crates/ironclaw_gateway/static/i18n/zh-CN.js b/crates/ironclaw_gateway/static/i18n/zh-CN.js index 188740a266..0104af22f1 100644 --- a/crates/ironclaw_gateway/static/i18n/zh-CN.js +++ b/crates/ironclaw_gateway/static/i18n/zh-CN.js @@ -39,6 +39,7 @@ I18n.register('zh-CN', { 'tab.chat': '聊天', 'tab.memory': '记忆', 'tab.jobs': '任务', + 'tab.projects': '项目', 'tab.missions': '使命', 'tab.routines': '定时任务', 'tab.settings': '设置', diff --git a/tests/support/live_harness.rs b/tests/support/live_harness.rs index 093bd1dbc9..7ee4cde95c 100644 --- a/tests/support/live_harness.rs +++ b/tests/support/live_harness.rs @@ -194,21 +194,20 @@ impl LiveTestHarness { let needle_lc = needle.to_ascii_lowercase(); for event in self.rig.captured_status_events() { match event { - StatusUpdate::ToolStarted { name, detail, .. } - if name.to_ascii_lowercase().contains(&tool_lc) => + StatusUpdate::ToolStarted { + name, + detail: Some(d), + .. + } if name.to_ascii_lowercase().contains(&tool_lc) + && d.to_ascii_lowercase().contains(&needle_lc) => { - if let Some(d) = detail - && d.to_ascii_lowercase().contains(&needle_lc) - { - return true; - } + return true; } StatusUpdate::ToolResult { name, preview, .. } - if name.to_ascii_lowercase().contains(&tool_lc) => + if name.to_ascii_lowercase().contains(&tool_lc) + && preview.to_ascii_lowercase().contains(&needle_lc) => { - if preview.to_ascii_lowercase().contains(&needle_lc) { - return true; - } + return true; } _ => {} }