diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6421e4d4..adfc59fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,10 +166,10 @@ jobs: include: - arch: arm64 runner: macos-14 - dist_command: pnpm dist:mac:arm64 + dist_command: pnpm pack:mac:arm64 - arch: x64 runner: macos-15-intel - dist_command: pnpm dist:mac:x64 + dist_command: pnpm pack:mac:x64 runs-on: ${{ matrix.runner }} steps: @@ -206,32 +206,18 @@ jobs: VERSION="${GITHUB_REF#refs/tags/v}" pnpm pkg set version="$VERSION" - - name: Resolve runtime asset name (macOS ${{ matrix.arch }}) - if: startsWith(github.ref, 'refs/tags/v') - id: runtime-asset - shell: bash - run: | - set -euo pipefail - if [[ "${{ matrix.arch }}" == "arm64" ]]; then - platform="darwin-arm64" - else - platform="darwin-x64" - fi - echo "asset_name=$(node ./scripts/runtime-lock.mjs asset-name "$platform")" >> "$GITHUB_OUTPUT" - - name: Stage bundled runtime (macOS ${{ matrix.arch }}) - if: startsWith(github.ref, 'refs/tags/v') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail - TAG="${GITHUB_REF#refs/tags/}" - rm -rf .runtime-download resources/runtime - mkdir -p .runtime-download resources/runtime - gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "${{ steps.runtime-asset.outputs.asset_name }}" --dir .runtime-download - tar -xzf ".runtime-download/${{ steps.runtime-asset.outputs.asset_name }}" -C .runtime-download - cp -R .runtime-download/runtime/. resources/runtime/ + if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then + TAG="${GITHUB_REF#refs/tags/}" + node ./scripts/stage-runtime.mjs --platform "darwin-${{ matrix.arch }}" --release-tag "$TAG" + else + node ./scripts/stage-runtime.mjs --platform "darwin-${{ matrix.arch }}" + fi - name: Build app (macOS ${{ matrix.arch }}) env: @@ -248,9 +234,7 @@ jobs: test -f dist-electron/preload/index.js test -f out/renderer/index.html test -f mcp-server/dist/index.js - if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then - test -f resources/runtime/VERSION - fi + test -f resources/runtime/VERSION - name: Package (macOS ${{ matrix.arch }}) env: @@ -321,28 +305,18 @@ jobs: VERSION="${GITHUB_REF#refs/tags/v}" pnpm pkg set version="$VERSION" - - name: Resolve runtime asset name (Windows) - if: startsWith(github.ref, 'refs/tags/v') - id: runtime-asset - shell: bash - run: | - echo "asset_name=$(node ./scripts/runtime-lock.mjs asset-name win32-x64)" >> "$GITHUB_OUTPUT" - - name: Stage bundled runtime (Windows) - if: startsWith(github.ref, 'refs/tags/v') shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | $ErrorActionPreference = "Stop" - $tag = $env:GITHUB_REF.Replace('refs/tags/', '') - Remove-Item .runtime-download -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item resources/runtime/* -Recurse -Force -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Force -Path .runtime-download | Out-Null - New-Item -ItemType Directory -Force -Path resources/runtime | Out-Null - gh release download $tag --repo $env:GITHUB_REPOSITORY --pattern "${{ steps.runtime-asset.outputs.asset_name }}" --dir .runtime-download - Expand-Archive -Path ".runtime-download/${{ steps.runtime-asset.outputs.asset_name }}" -DestinationPath .runtime-download/unpacked -Force - Copy-Item .runtime-download/unpacked/runtime/* resources/runtime -Recurse -Force + if ($env:GITHUB_REF -like 'refs/tags/v*') { + $tag = $env:GITHUB_REF.Replace('refs/tags/', '') + node ./scripts/stage-runtime.mjs --platform win32-x64 --release-tag $tag + } else { + node ./scripts/stage-runtime.mjs --platform win32-x64 + } - name: Build app (Windows) env: @@ -360,14 +334,12 @@ jobs: test -f dist-electron/preload/index.js test -f out/renderer/index.html test -f mcp-server/dist/index.js - if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then - test -f resources/runtime/VERSION - fi + test -f resources/runtime/VERSION - name: Package (Windows) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: pnpm dist:win --publish never + run: pnpm pack:win --publish never - name: Validate packaged bundle (Windows) shell: bash @@ -435,26 +407,18 @@ jobs: VERSION="${GITHUB_REF#refs/tags/v}" pnpm pkg set version="$VERSION" - - name: Resolve runtime asset name (Linux) - if: startsWith(github.ref, 'refs/tags/v') - id: runtime-asset - shell: bash - run: | - echo "asset_name=$(node ./scripts/runtime-lock.mjs asset-name linux-x64)" >> "$GITHUB_OUTPUT" - - name: Stage bundled runtime (Linux) - if: startsWith(github.ref, 'refs/tags/v') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail - TAG="${GITHUB_REF#refs/tags/}" - rm -rf .runtime-download resources/runtime - mkdir -p .runtime-download resources/runtime - gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "${{ steps.runtime-asset.outputs.asset_name }}" --dir .runtime-download - tar -xzf ".runtime-download/${{ steps.runtime-asset.outputs.asset_name }}" -C .runtime-download - cp -R .runtime-download/runtime/. resources/runtime/ + if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then + TAG="${GITHUB_REF#refs/tags/}" + node ./scripts/stage-runtime.mjs --platform linux-x64 --release-tag "$TAG" + else + node ./scripts/stage-runtime.mjs --platform linux-x64 + fi - name: Build app (Linux) env: @@ -471,14 +435,12 @@ jobs: test -f dist-electron/preload/index.js test -f out/renderer/index.html test -f mcp-server/dist/index.js - if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then - test -f resources/runtime/VERSION - fi + test -f resources/runtime/VERSION - name: Package (Linux) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: pnpm dist:linux --publish never + run: pnpm pack:linux --publish never - name: Validate packaged bundle (Linux) run: node ./scripts/electron-builder/verifyBundle.cjs "release/linux-unpacked" linux x64 diff --git a/.gitignore b/.gitignore index 57dfbc28..c0876432 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,10 @@ remotion/* .board-task-log-freshness/ .serena/ +.playwright-mcp/ # Local release operator notes /ORCHESTRATOR_RELEASE_RUNBOOK.local.md + +# Local reference captures +/agent-teams-reference-fix-*.png diff --git a/README.md b/README.md index 7b547385..c32e26f8 100644 --- a/README.md +++ b/README.md @@ -300,9 +300,13 @@ pnpm dist:mac:arm64 # macOS Apple Silicon (.dmg) pnpm dist:mac:x64 # macOS Intel (.dmg) pnpm dist:win # Windows (.exe) pnpm dist:linux # Linux (AppImage/.deb/.rpm/.pacman) -pnpm dist # macOS + Windows + Linux +pnpm dist # Current platform ``` +Distribution scripts run the production build and stage the bundled multimodel runtime from +`runtime.lock.json` before packaging. Use `pnpm clean:runtime` to remove staged runtime files after +local packaging. + ### Scripts | Command | Description | diff --git a/agent-teams-controller/src/internal/atomicFile.js b/agent-teams-controller/src/internal/atomicFile.js new file mode 100644 index 00000000..f859c746 --- /dev/null +++ b/agent-teams-controller/src/internal/atomicFile.js @@ -0,0 +1,90 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const RENAME_MAX_ATTEMPTS = 8; +const RENAME_RETRY_BASE_DELAY_MS = 40; +const RENAME_RETRY_MAX_DELAY_MS = 250; +const RENAME_RETRY_JITTER_MS = 25; +const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']); + +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function getRenameRetryDelayMs(attempt) { + const backoff = Math.min(RENAME_RETRY_BASE_DELAY_MS * attempt, RENAME_RETRY_MAX_DELAY_MS); + return backoff + Math.floor(Math.random() * (RENAME_RETRY_JITTER_MS + 1)); +} + +function fsyncFileBestEffort(filePath) { + let fd = null; + try { + fd = fs.openSync(filePath, 'r+'); + fs.fsyncSync(fd); + } catch { + // Best effort only. Some filesystems do not support fsync for these files. + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // Best effort only. + } + } + } +} + +function renameWithRetrySync(tempPath, filePath) { + for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt += 1) { + try { + fs.renameSync(tempPath, filePath); + return; + } catch (error) { + if (error && error.code === 'EXDEV') { + fs.copyFileSync(tempPath, filePath); + try { + fs.rmSync(tempPath, { force: true }); + } catch { + // Best effort cleanup after cross-device fallback. + } + return; + } + + if (error && RETRYABLE_RENAME_CODES.has(error.code) && attempt < RENAME_MAX_ATTEMPTS) { + sleepSync(getRenameRetryDelayMs(attempt)); + continue; + } + + throw error; + } + } +} + +function atomicWriteFileSync(filePath, data, options) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = path.join(path.dirname(filePath), `.tmp.${crypto.randomUUID()}`); + + try { + fs.writeFileSync(tempPath, data, options); + fsyncFileBestEffort(tempPath); + renameWithRetrySync(tempPath, filePath); + } catch (error) { + try { + fs.rmSync(tempPath, { force: true }); + } catch { + // Cleanup is best effort. Preserve the original write error. + } + throw error; + } +} + +function writeJsonFileSync(filePath, value, options = {}) { + const suffix = options.trailingNewline === true ? '\n' : ''; + atomicWriteFileSync(filePath, `${JSON.stringify(value, null, 2)}${suffix}`, 'utf8'); +} + +module.exports = { + atomicWriteFileSync, + writeJsonFileSync, +}; diff --git a/agent-teams-controller/src/internal/crossTeam.js b/agent-teams-controller/src/internal/crossTeam.js index 9b9a3834..487a96ab 100644 --- a/agent-teams-controller/src/internal/crossTeam.js +++ b/agent-teams-controller/src/internal/crossTeam.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const { writeJsonFileSync } = require('./atomicFile.js'); const { createControllerContext } = require('./context.js'); const { withFileLockSync } = require('./fileLock.js'); const messageStore = require('./messageStore.js'); @@ -25,10 +26,7 @@ function readJson(filePath, fallbackValue) { } function writeJson(filePath, value) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(value, null, 2)); - fs.renameSync(tempPath, filePath); + writeJsonFileSync(filePath, value); } function normalizeMetaMembers(rawMembers) { diff --git a/agent-teams-controller/src/internal/kanbanStore.js b/agent-teams-controller/src/internal/kanbanStore.js index 8b3414e5..dc5dffbb 100644 --- a/agent-teams-controller/src/internal/kanbanStore.js +++ b/agent-teams-controller/src/internal/kanbanStore.js @@ -1,6 +1,6 @@ const fs = require('fs'); -const path = require('path'); const taskStore = require('./taskStore.js'); +const { writeJsonFileSync } = require('./atomicFile.js'); function nowIso() { return new Date().toISOString(); @@ -15,10 +15,7 @@ function readJson(filePath, fallbackValue) { } function writeJson(filePath, value) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(value, null, 2)); - fs.renameSync(tempPath, filePath); + writeJsonFileSync(filePath, value); } function getDefaultState(teamName) { diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js index 524c243d..2adfebf8 100644 --- a/agent-teams-controller/src/internal/messageStore.js +++ b/agent-teams-controller/src/internal/messageStore.js @@ -1,15 +1,12 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const { writeJsonFileSync } = require('./atomicFile.js'); function nowIso() { return new Date().toISOString(); } -function ensureDir(dirPath) { - fs.mkdirSync(dirPath, { recursive: true }); -} - function readJson(filePath, fallbackValue) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); @@ -19,10 +16,7 @@ function readJson(filePath, fallbackValue) { } function writeJson(filePath, value) { - ensureDir(path.dirname(filePath)); - const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(value, null, 2)); - fs.renameSync(tempPath, filePath); + writeJsonFileSync(filePath, value); } function getInboxPath(paths, memberName) { diff --git a/agent-teams-controller/src/internal/processStore.js b/agent-teams-controller/src/internal/processStore.js index f2af6245..7e99ed6d 100644 --- a/agent-teams-controller/src/internal/processStore.js +++ b/agent-teams-controller/src/internal/processStore.js @@ -1,7 +1,7 @@ const fs = require('fs'); -const path = require('path'); const crypto = require('crypto'); +const { writeJsonFileSync } = require('./atomicFile.js'); const runtimeHelpers = require('./runtimeHelpers.js'); function nowIso() { @@ -17,10 +17,7 @@ function readJson(filePath, fallbackValue) { } function writeJson(filePath, value) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(value, null, 2)); - fs.renameSync(tempPath, filePath); + writeJsonFileSync(filePath, value); } function readProcesses(paths) { diff --git a/agent-teams-controller/src/internal/runtime.js b/agent-teams-controller/src/internal/runtime.js index ed328839..af0c8e55 100644 --- a/agent-teams-controller/src/internal/runtime.js +++ b/agent-teams-controller/src/internal/runtime.js @@ -49,6 +49,18 @@ function uniqueNonEmpty(items) { return [...new Set(items.filter((item) => typeof item === 'string' && item.trim()))]; } +function describeControlApiLookup(context, flags, stateFileUrl, envUrl) { + const explicit = + (typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) || + (typeof flags['control-url'] === 'string' && flags['control-url'].trim()) || + ''; + return [ + `explicit=${explicit ? 'set' : 'missing'}`, + `stateFile=${stateFileUrl ? 'set' : `missing:${getControlApiStatePath(context)}`}`, + `env=${envUrl ? 'set' : 'missing:CLAUDE_TEAM_CONTROL_URL'}`, + ].join(', '); +} + function resolveControlBaseUrls(context, flags = {}) { const explicit = (typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) || @@ -63,7 +75,12 @@ function resolveControlBaseUrls(context, flags = {}) { if (candidates.length === 0) { throw new Error( - 'Team control API is unavailable. Start the desktop app team runtime first so it can publish CLAUDE_TEAM_CONTROL_URL.' + `Team control API is unavailable. Start the desktop app team runtime first so it can publish CLAUDE_TEAM_CONTROL_URL. Lookup: ${describeControlApiLookup( + context, + flags, + stateFileUrl, + envUrl + )}.` ); } diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js index cc070872..13ebbecc 100644 --- a/agent-teams-controller/src/internal/taskStore.js +++ b/agent-teams-controller/src/internal/taskStore.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const { writeJsonFileSync } = require('./atomicFile.js'); const reviewStateHelpers = require('./reviewState.js'); const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']); @@ -24,10 +25,7 @@ function readJson(filePath, fallbackValue) { } function writeJson(filePath, value) { - ensureDir(path.dirname(filePath)); - const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(value, null, 2)); - fs.renameSync(tempPath, filePath); + writeJsonFileSync(filePath, value); } function getTaskPath(paths, taskId) { diff --git a/agent-teams-controller/src/internal/workSync.js b/agent-teams-controller/src/internal/workSync.js index fd17af45..41f78c08 100644 --- a/agent-teams-controller/src/internal/workSync.js +++ b/agent-teams-controller/src/internal/workSync.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const runtimeHelpers = require('./runtimeHelpers.js'); +const { writeJsonFileSync } = require('./atomicFile.js'); const { withFileLockSync } = require('./fileLock.js'); const DEFAULT_WAIT_TIMEOUT_MS = 10000; @@ -30,6 +31,20 @@ function readControlApiState(context) { } } +function describeControlApiLookup(context, flags, stateFileUrl, envUrl) { + const explicit = + (typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) || + (typeof flags['control-url'] === 'string' && flags['control-url'].trim()) || + ''; + return [ + `explicit=${explicit ? 'set' : 'missing'}`, + `stateFile=${ + stateFileUrl ? 'set' : `missing:${path.join(context.claudeDir, TEAM_CONTROL_API_STATE_FILE)}` + }`, + `env=${envUrl ? 'set' : 'missing:CLAUDE_TEAM_CONTROL_URL'}`, + ].join(', '); +} + function resolveControlBaseUrls(context, flags = {}) { const explicit = (typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) || @@ -43,7 +58,12 @@ function resolveControlBaseUrls(context, flags = {}) { const candidates = [...new Set([explicit, stateFileUrl, envUrl].filter(Boolean))]; if (candidates.length === 0) { throw new Error( - 'Team control API is unavailable. Start the desktop app team runtime first so it can validate member work sync reports.' + `Team control API is unavailable. Start the desktop app team runtime first so it can validate member work sync reports. Lookup: ${describeControlApiLookup( + context, + flags, + stateFileUrl, + envUrl + )}.` ); } return candidates; @@ -139,7 +159,9 @@ function buildPendingIntentId(body) { : []; const payload = { teamName: body.teamName, - memberName: String(body.memberName || '').trim().toLowerCase(), + memberName: String(body.memberName || '') + .trim() + .toLowerCase(), state: body.state, agendaFingerprint: body.agendaFingerprint, reportToken: body.reportToken || '', @@ -176,10 +198,7 @@ function readPendingReportFile(filePath) { } function writePendingReportFile(filePath, data) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); - fs.renameSync(tempPath, filePath); + writeJsonFileSync(filePath, data, { trailingNewline: true }); } function appendPendingReportIntent(context, body, reason) { diff --git a/agent-teams-controller/test/atomicFile.test.js b/agent-teams-controller/test/atomicFile.test.js new file mode 100644 index 00000000..9395d97c --- /dev/null +++ b/agent-teams-controller/test/atomicFile.test.js @@ -0,0 +1,94 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { writeJsonFileSync } = require('../src/internal/atomicFile.js'); + +function listTempFiles(dir) { + return fs.readdirSync(dir).filter((name) => name.includes('.tmp.')); +} + +function withMockedRenameSync(mockRenameSync, callback) { + const originalRenameSync = fs.renameSync; + fs.renameSync = (from, to) => mockRenameSync(from, to, originalRenameSync); + try { + callback(); + } finally { + fs.renameSync = originalRenameSync; + } +} + +describe('atomic file writes', () => { + ['EPERM', 'EACCES', 'EBUSY'].forEach((code) => { + it(`retries transient ${code} rename failures before publishing JSON`, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-')); + const filePath = path.join(dir, 'state.json'); + let attempts = 0; + + withMockedRenameSync( + (from, to, originalRenameSync) => { + attempts += 1; + if (attempts < 3) { + const error = new Error(`simulated transient ${code}`); + error.code = code; + throw error; + } + return originalRenameSync.call(fs, from, to); + }, + () => { + writeJsonFileSync(filePath, { ok: true }); + } + ); + + expect(attempts).toBe(3); + expect(JSON.parse(fs.readFileSync(filePath, 'utf8'))).toEqual({ ok: true }); + expect(listTempFiles(dir)).toEqual([]); + }); + }); + + it('does not retry ENOENT rename failures and removes the temp file', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-')); + const filePath = path.join(dir, 'state.json'); + let attempts = 0; + + withMockedRenameSync( + () => { + attempts += 1; + const error = new Error('missing target directory'); + error.code = 'ENOENT'; + throw error; + }, + () => { + expect(() => writeJsonFileSync(filePath, { ok: true })).toThrow('missing target directory'); + } + ); + + expect(attempts).toBe(1); + expect(fs.existsSync(filePath)).toBe(false); + expect(listTempFiles(dir)).toEqual([]); + }); + + it('removes the temp file after retryable rename failures are exhausted', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-')); + const filePath = path.join(dir, 'state.json'); + let attempts = 0; + + withMockedRenameSync( + () => { + attempts += 1; + const error = new Error('transient lock stayed active'); + error.code = 'EBUSY'; + throw error; + }, + () => { + expect(() => writeJsonFileSync(filePath, { ok: true })).toThrow( + 'transient lock stayed active' + ); + } + ); + + expect(attempts).toBe(8); + expect(fs.existsSync(filePath)).toBe(false); + expect(listTempFiles(dir)).toEqual([]); + }); +}); diff --git a/docs/research/future-orchestrator-competitor-patterns.md b/docs/research/future-orchestrator-competitor-patterns.md new file mode 100644 index 00000000..cc1963ce --- /dev/null +++ b/docs/research/future-orchestrator-competitor-patterns.md @@ -0,0 +1,283 @@ +# Будущий оркестратор: что взять из Gas City, GoClaw и Gas Town + +**Дата**: 2026-05-15 +**Статус**: research note для нового проекта оркестратора +**Контекст**: не план миграции текущего Electron-приложения, а список идей, которые стоит заложить в новый фундамент. + +## Короткий вердикт + +RabbitMQ, Kafka и gRPC решают транспорт и обмен сообщениями, но не решают главную боль оркестратора: где находится каноническое состояние, кто владеет lifecycle агента, как восстанавливаться после падений, как доказывать что агент действительно сделал работу, и как не превратить UI, файлы, процессы и очереди в четыре разных источника правды. + +Из изученных проектов лучший архитектурный reference - **Gas City**. Самые полезные backend-идеи по reliability - **GoClaw**. Самые полезные UX и team-workflow идеи - **Gas Town**, но его tmux/file substrate не стоит копировать как control plane. + +## Топ-3 источника идей + +| Источник | Что взять | Оценка | Примерный объём для MVP | +|----------|-----------|--------|--------------------------| +| **Gas City / gascity** | Provider interface, typed HTTP API, SSE events, async `request_id`, `event_cursor`, session state machine, trust boundaries, разделение supervisor/city | 🎯 9 🛡️ 8 🧠 8 | ~3000-9000 LOC | +| **GoClaw / goclaw** | Durable task lifecycle, scheduler lanes, periodic recovery, stale/orphan repair, явные failure domains | 🎯 8 🛡️ 8 🧠 7 | ~2500-7000 LOC | +| **Gas Town / gastown** | Роли агентов, mail/nudge модель, operator UX, recover/doctor mindset, простота запуска команды | 🎯 8 🛡️ 5.5 🧠 7 | ~1500-5000 LOC, если брать выборочно | + +Оценки: +- 🎯 - уверенность, что идея полезна для нас. +- 🛡️ - надёжность паттерна как основы продукта. +- 🧠 - сложность внедрения, где 10 значит сложно. + +## Что взять в новый оркестратор + +### 1. Runtime Provider Contract + +Из Gas City стоит взять идею строгого runtime-контракта для каждого агента: Claude, Codex, OpenCode и будущие adapters не должны жить внутри одного огромного сервиса. + +Минимальный контракт: + +```ts +interface RuntimeProvider { + kind: 'claude' | 'codex' | 'opencode' | string; + + startSession(input: StartSessionInput): Promise; + sendInput(input: SendInputInput): Promise; + cancelSession(input: CancelSessionInput): Promise; + inspectSession(input: InspectSessionInput): Promise; + recoverSession(input: RecoverSessionInput): Promise; +} +``` + +Ключевая мысль: adapter - это не просто `spawn(command)`. Он должен уметь сообщать состояние, восстанавливаться, подтверждать readiness, отдавать evidence и нормально завершаться. + +### 2. Typed Control Plane + +Gas City хорош тем, что у него есть явный API-слой, а не скрытая связка UI -> process manager -> files -> CLI. + +Для нового оркестратора стоит делать: +- gRPC для внутренних сервисов и adapter workers. +- HTTP/OpenAPI для внешней интеграции, CLI и dashboard. +- SSE или WebSocket для UI projection. +- Асинхронные команды в стиле `202 Accepted + request_id + event_cursor`. + +Пример: + +```txt +POST /runs + -> 202 Accepted + -> { request_id, run_id, event_cursor } + +GET /events?cursor=... + -> append-only stream of state changes +``` + +Это лучше, чем блокирующий запуск агента, потому что запуск CLI, auth, checkout, bootstrap и readiness могут занимать непредсказуемое время. + +### 3. Durable Task Lifecycle + +Из GoClaw стоит взять явную task model и scheduler, где task не является просто строкой в prompt или карточкой в UI. + +Состояния должны быть формализованы: + +```txt +queued -> assigned -> starting -> running -> blocked -> review -> completed + -> failed + -> cancelled + -> stale +``` + +Важно: +- Все переходы идут через один доменный сервис. +- Каждый переход пишет событие. +- UI только читает projection. +- Adapter не решает сам, что задача завершена, он присылает signal/evidence, а orchestrator принимает transition. + +### 4. Recovery And Reconciliation Loops + +У GoClaw сильная идея periodic recovery ticker: система не верит, что всё всегда завершится идеально. + +Для нового оркестратора нужны фоновые reconciler-процессы: +- Найти runs, которые `starting` слишком долго. +- Найти agents без heartbeat. +- Найти task, где process умер, но state всё ещё `running`. +- Найти orphan workspace/process/session. +- Повторить idempotent-команды. +- Сформировать launch-failure artifact с redacted логами. + +Это должно быть частью core, а не набором debug scripts. + +### 5. Inter-Agent Mail And Nudge + +Из Gas Town стоит взять product-идею: агенты не только выполняют task, но и умеют общаться, просить review, пинговать teammate, отдавать status и handoff. + +Но реализация должна быть не через tmux pane и не через ad hoc text capture. + +Правильнее: +- `messages` table как durable inbox/outbox. +- Типизированные сообщения: `question`, `status`, `handoff`, `review_request`, `decision`, `blocker`. +- Causal links: `task_id`, `run_id`, `parent_message_id`. +- Delivery state: `created`, `delivered`, `seen_by_adapter`, `acknowledged`. +- UI строит thread projection из canonical store. + +### 6. Trust Boundaries + +Из Gas City нужно взять идею явных trust boundaries: orchestrator, adapter, workspace, user secret store и UI не должны иметь одинаковые права. + +Практические правила: +- Adapter worker получает только нужный workspace и scoped credentials. +- UI не исполняет команды и не пишет напрямую в runtime state. +- CLI tools не получают прямой доступ к master database без API boundary. +- Secrets не идут через event stream. +- Все dangerous operations требуют policy decision: filesystem, shell, git push, network, secrets, browser. + +### 7. Evidence Model + +Для больших проектов на сотни тысяч строк важно не просто “агент сказал done”. + +Нужно хранить evidence: +- terminal tail +- diff summary +- changed files snapshot +- test command result +- lint/typecheck result +- agent final answer +- tool calls summary +- review verdict + +Идея: task completion без evidence - это не completion, а claim. + +### 8. UI As Projection, Not Source Of Truth + +Текущая боль обычно появляется, когда Electron UI, IPC handlers, JSON files, process registry и runtime adapter все частично владеют состоянием. + +Для нового проекта: +- Canonical truth: Postgres. +- Event truth: append-only outbox/events. +- UI truth: read model/projection. +- Process truth: runtime snapshots, которые reconciler сверяет с canonical state. + +UI должен уметь умереть и открыться заново без потери смысла происходящего. + +## Что не копировать + +### Не копировать tmux как control plane + +Gas Town полезен как workflow reference, но tmux/send-keys/capture-pane не должен быть foundation для нового мощного оркестратора. + +Проблемы: +- readiness часто эвристический +- сложно гарантировать доставку сообщений +- сложно делать typed state transitions +- сложно изолировать права +- сложно масштабировать на distributed workers + +tmux может быть optional local development view, но не backend truth. + +### Не делать CLI as RPC + +CLI можно поддерживать как adapter transport, но нельзя строить весь control plane на парсинге stdout/stderr. + +Правильнее: +- CLI adapter нормализует output в typed events. +- Core orchestrator не знает про ANSI, panes, terminal prompt и escape sequences. +- Provider-specific parsing живёт внутри provider adapter. + +### Не использовать in-memory event bus как critical truth + +GoClaw показывает полезный event-bus mindset, но bounded in-process bus не должен быть источником истины. + +Для side effects - нормально. +Для lifecycle, audit и recovery - нужен durable outbox. + +### Не смешивать Kafka и RabbitMQ без причины + +Kafka и RabbitMQ решают разные задачи. + +Рекомендуемый baseline: +- RabbitMQ или NATS JetStream для commands/work queue. +- Kafka только если нужен большой durable event log, analytics, replay и интеграции. +- Для MVP чаще достаточно Postgres outbox + worker polling или NATS. + +Иначе получится сложность распределённой системы до того, как появится понятная domain model. + +## Целевая архитектура для нового проекта + +```mermaid +flowchart LR + UI["Electron/Web UI"] --> API["Control Plane API"] + CLI["CLI / SDK"] --> API + API --> DB["Postgres canonical state"] + API --> OUTBOX["Durable outbox"] + OUTBOX --> EVENTS["SSE/WebSocket projections"] + OUTBOX --> WORKQ["NATS/RabbitMQ work queue"] + API --> WF["Temporal workflows"] + WF --> WORKQ + WORKQ --> A1["Claude adapter worker"] + WORKQ --> A2["Codex adapter worker"] + WORKQ --> A3["OpenCode adapter worker"] + A1 --> WS["Workspace / git worktree"] + A2 --> WS + A3 --> WS + REC["Reconciler"] --> DB + REC --> A1 + REC --> A2 + REC --> A3 +``` + +Рекомендуемый стек: +- **Postgres** - canonical state, locks, task lifecycle, sessions, messages, evidence. +- **Temporal** - long-running workflows: launch, cancel, retry, review, recovery. +- **gRPC** - internal adapter protocol. +- **HTTP/OpenAPI** - public control plane. +- **SSE/WebSocket** - live UI events. +- **NATS JetStream или RabbitMQ** - work queue. +- **Kafka** - позже, если нужен global event log, replay и analytics. + +## MVP vertical slice + +Чтобы не построить “идеальную” систему на год вперёд, первый MVP должен доказать один полный путь: + +1. Создать `run` и `task` в Postgres. +2. Запустить одного adapter worker через typed provider contract. +3. Получить live events в UI через cursor-based stream. +4. Сохранить evidence: output, diff summary, test result. +5. Убить worker и проверить, что reconciler переводит state в `failed` или восстанавливает run. + +Если этот путь работает, тогда добавлять: +- multi-agent team planning +- inter-agent mail +- review workflow +- multi-provider adapters +- distributed workers +- Kafka/event lake + +## Практический вывод для нас + +Главная ошибка была бы думать, что “микросервисы + RabbitMQ + Kafka + gRPC” автоматически делают систему грамотной. Грамотной её делают: +- один canonical state +- typed provider boundary +- durable lifecycle +- recovery loops +- strict trust boundaries +- UI как projection +- evidence-based completion + +Транспорт можно заменить. Потерянную domain model потом очень дорого чинить. + +## Источники + +- [Gas City repository](https://github.com/gastownhall/gascity) +- [Gas City API reference](https://raw.githubusercontent.com/gastownhall/gascity/main/docs/reference/api.md) +- [Gas City trust boundaries](https://raw.githubusercontent.com/gastownhall/gascity/main/docs/reference/trust-boundaries.md) +- [Gas City events reference](https://raw.githubusercontent.com/gastownhall/gascity/main/docs/reference/events.md) +- [Gas City runtime provider interface](https://raw.githubusercontent.com/gastownhall/gascity/main/internal/runtime/runtime.go) +- [Gas City session state machine](https://raw.githubusercontent.com/gastownhall/gascity/main/internal/session/state_machine.go) +- [Gas Town provider integration](https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md) +- [GoClaw repository](https://github.com/nextlevelbuilder/goclaw) +- [GoClaw task ticker](https://raw.githubusercontent.com/nextlevelbuilder/goclaw/dev/internal/tasks/task_ticker.go) +- [GoClaw scheduler](https://raw.githubusercontent.com/nextlevelbuilder/goclaw/dev/internal/scheduler/scheduler.go) +- [GoClaw domain event bus](https://raw.githubusercontent.com/nextlevelbuilder/goclaw/dev/internal/eventbus/domain_event_bus.go) + +## 📌 Summary + +Для нового мощного оркестратора стоит брать не готовый проект целиком, а паттерны: +- Gas City - control plane, provider contract, events, trust boundaries. +- GoClaw - task lifecycle, scheduler, recovery loops. +- Gas Town - team UX, mail/nudge, operator workflow. + +Не стоит копировать tmux/file-first подход как фундамент. База должна быть durable: Postgres + workflows + typed adapters + event stream. diff --git a/docs/research/orchestrator-as-foundation.md b/docs/research/orchestrator-as-foundation.md index db3bc2e1..b7b45feb 100644 --- a/docs/research/orchestrator-as-foundation.md +++ b/docs/research/orchestrator-as-foundation.md @@ -3,6 +3,8 @@ **Дата**: 2026-03-25 **Вопрос**: стоит ли взять готовый multi-agent оркестратор и посадить наш Electron UI сверху, вместо того чтобы развивать собственный TeamProvisioningService? +**Связанная заметка**: [будущий оркестратор: паттерны из Gas City, GoClaw и Gas Town](future-orchestrator-competitor-patterns.md) + --- ## 1. Что мы бы заменяли (наш текущий стек) diff --git a/landing/assets/images/hero/backgrounds/cyber-city-desktop-v1.webp b/landing/assets/images/hero/backgrounds/cyber-city-desktop-v1.webp new file mode 100644 index 00000000..17c626bd Binary files /dev/null and b/landing/assets/images/hero/backgrounds/cyber-city-desktop-v1.webp differ diff --git a/landing/assets/images/hero/backgrounds/cyber-city-mobile-v1.webp b/landing/assets/images/hero/backgrounds/cyber-city-mobile-v1.webp new file mode 100644 index 00000000..66490a1e Binary files /dev/null and b/landing/assets/images/hero/backgrounds/cyber-city-mobile-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-amber-v1.webp b/landing/assets/images/hero/robots/robot-amber-v1.webp new file mode 100644 index 00000000..917760a0 Binary files /dev/null and b/landing/assets/images/hero/robots/robot-amber-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-cyan-v1.webp b/landing/assets/images/hero/robots/robot-cyan-v1.webp new file mode 100644 index 00000000..5670a0dd Binary files /dev/null and b/landing/assets/images/hero/robots/robot-cyan-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-magenta-v1.webp b/landing/assets/images/hero/robots/robot-magenta-v1.webp new file mode 100644 index 00000000..4f544ee5 Binary files /dev/null and b/landing/assets/images/hero/robots/robot-magenta-v1.webp differ diff --git a/landing/assets/images/references/cyberpunk-robots-hero-reference-2026-05-15.png b/landing/assets/images/references/cyberpunk-robots-hero-reference-2026-05-15.png new file mode 100644 index 00000000..9817d609 Binary files /dev/null and b/landing/assets/images/references/cyberpunk-robots-hero-reference-2026-05-15.png differ diff --git a/landing/assets/images/references/cyberpunk-robots-hero-reference-2026-05-15.webp b/landing/assets/images/references/cyberpunk-robots-hero-reference-2026-05-15.webp new file mode 100644 index 00000000..a3133d01 Binary files /dev/null and b/landing/assets/images/references/cyberpunk-robots-hero-reference-2026-05-15.webp differ diff --git a/landing/assets/styles/cyberpunk-hero.scss b/landing/assets/styles/cyberpunk-hero.scss new file mode 100644 index 00000000..2018b34b --- /dev/null +++ b/landing/assets/styles/cyberpunk-hero.scss @@ -0,0 +1,1092 @@ +:root { + --cyber-bg-0: #02050d; + --cyber-bg-1: #050814; + --cyber-panel-weak: rgba(3, 10, 22, 0.58); + --cyber-panel: rgba(3, 10, 22, 0.72); + --cyber-panel-strong: rgba(5, 14, 31, 0.88); + --cyber-cyan: #00eaff; + --cyber-blue: #2f7dff; + --cyber-magenta: #ff2bff; + --cyber-violet: #8b5cff; + --cyber-amber: #ffb238; + --cyber-red: #ff4c6a; + --cyber-text: #f4f7ff; + --cyber-muted: #9ba8c7; + --cyber-border-cyan: rgba(0, 234, 255, 0.42); + --cyber-border-magenta: rgba(255, 43, 255, 0.42); + --cyber-radius-xs: 4px; + --cyber-radius-sm: 6px; + --cyber-radius-md: 8px; + --cyber-frame-cut: 18px; +} + +.cyber-panel { + position: relative; + border: 1px solid var(--cyber-border-cyan); + background: + linear-gradient(135deg, rgba(5, 14, 31, 0.9), rgba(3, 10, 22, 0.64)); + clip-path: polygon( + var(--cyber-frame-cut) 0, + 100% 0, + 100% calc(100% - var(--cyber-frame-cut)), + calc(100% - var(--cyber-frame-cut)) 100%, + 0 100%, + 0 var(--cyber-frame-cut) + ); + box-shadow: + 0 0 0 1px rgba(47, 125, 255, 0.12) inset, + 0 0 24px rgba(0, 234, 255, 0.12); +} + +.cyber-hero { + --hero-pointer-x: 0; + --hero-pointer-y: 0; + --hero-scroll: 0; + --hero-tilt-x: 0; + --hero-tilt-y: 0; + + position: relative; + min-height: min(980px, 100svh); + padding: clamp(154px, 17svh, 210px) 0 30px; + display: flex; + align-items: flex-start; + isolation: isolate; + overflow: clip; + color: var(--cyber-text); + background: + radial-gradient(circle at 72% 24%, rgba(0, 234, 255, 0.15), transparent 34%), + linear-gradient(180deg, var(--cyber-bg-0), var(--cyber-bg-1) 58%, var(--cyber-bg-0)); +} + +.cyber-hero__background, +.cyber-hero__wash, +.cyber-hero__gridlines, +.cyber-hero__scanlines { + position: absolute; + inset: 0; + pointer-events: none; +} + +.cyber-hero__background { + z-index: -4; + inset: -40px -40px -80px; + background-image: url("~/assets/images/hero/backgrounds/cyber-city-desktop-v1.webp"); + background-size: cover; + background-position: 58% top; + opacity: 1; + transform: translate3d( + calc(var(--hero-pointer-x) * -8px), + calc(var(--hero-scroll) * 0.035px + var(--hero-pointer-y) * -5px), + 0 + ) scale(1.035); + will-change: transform; +} + +.cyber-hero__background::after { + content: ""; + position: absolute; + inset: 0; + background-image: url("~/assets/images/hero/backgrounds/cyber-city-desktop-v1.webp"); + background-size: cover; + background-position: right top; + opacity: 0.78; + transform: scaleX(-1); + filter: saturate(1.08) contrast(1.12) brightness(0.72); + mix-blend-mode: screen; + mask-image: linear-gradient(90deg, black 0 16%, rgba(0, 0, 0, 0.72) 38%, transparent 64%); +} + +.cyber-hero__wash { + z-index: -3; + background: + radial-gradient(circle at 18% 44%, rgba(2, 5, 13, 0.48), rgba(2, 5, 13, 0.22) 34%, transparent 58%), + linear-gradient(90deg, rgba(2, 5, 13, 0.42) 0%, rgba(2, 5, 13, 0.28) 34%, rgba(2, 5, 13, 0.08) 66%, rgba(2, 5, 13, 0.3) 100%), + linear-gradient(180deg, rgba(2, 5, 13, 0.18), rgba(2, 5, 13, 0.08) 58%, rgba(2, 5, 13, 0.92)); +} + +.cyber-hero__gridlines { + z-index: -2; + opacity: 0.34; + background-image: + linear-gradient(rgba(0, 234, 255, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 234, 255, 0.045) 1px, transparent 1px); + background-size: 72px 72px; + mask-image: linear-gradient(180deg, transparent, black 12%, black 72%, transparent); +} + +.cyber-hero__scanlines { + z-index: 8; + opacity: 0.11; + background-image: repeating-linear-gradient( + to bottom, + rgba(255, 255, 255, 0.08) 0, + rgba(255, 255, 255, 0.08) 1px, + transparent 1px, + transparent 4px + ); + mix-blend-mode: overlay; +} + +.cyber-hero__container { + position: relative; + z-index: 2; + width: min(1580px, calc(100vw - 56px)); + max-width: none !important; +} + +.cyber-hero__layout { + display: grid; + grid-template-columns: minmax(420px, 0.82fr) minmax(620px, 1.18fr); + align-items: center; + gap: clamp(28px, 4vw, 74px); +} + +.cyber-hero__copy { + position: relative; + z-index: 6; + width: min(620px, 41vw); + max-width: 620px; + padding: 24px 0 24px; +} + +.cyber-hero__copy::before { + content: ""; + position: absolute; + inset: -64px -64px -42px -36px; + z-index: -1; + background: radial-gradient(circle at 28% 38%, rgba(2, 5, 13, 0.82), rgba(2, 5, 13, 0.36) 62%, transparent 78%); + pointer-events: none; +} + +.cyber-hero__brand-lockup { + display: inline-flex; + align-items: center; + gap: 12px; + margin-bottom: 44px; + font-weight: 800; + letter-spacing: 0.01em; + color: var(--cyber-text); +} + +.cyber-hero__brand-lockup span { + font-size: 1.02rem; +} + +.cyber-hero__brand-lockup span::first-letter { + color: var(--cyber-cyan); +} + +.cyber-hero__logo { + width: 52px; + height: 52px; + border-radius: 14px; + box-shadow: 0 0 24px rgba(139, 92, 255, 0.32); +} + +.cyber-hero__title { + margin: 0 0 22px; + display: flex; + flex-direction: column; + flex-wrap: wrap; + gap: 0; + font-size: clamp(4.2rem, 6.3vw, 6.25rem); + line-height: 0.95; + font-weight: 900; + letter-spacing: 0; + color: rgba(244, 247, 255, 0.96); +} + +.cyber-hero__title-accent { + background: linear-gradient(110deg, var(--cyber-cyan), var(--cyber-blue) 48%, var(--cyber-magenta)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + filter: drop-shadow(0 0 18px rgba(0, 234, 255, 0.22)); +} + +.cyber-hero__slogan { + display: inline-flex; + margin: 0 0 22px; + padding: 10px 18px 11px 22px; + max-width: 100%; + font-family: var(--at-font-mono); + font-size: clamp(0.76rem, 0.86vw, 0.98rem); + line-height: 1.3; + letter-spacing: 0.08em; + color: var(--cyber-cyan); + text-shadow: 0 0 18px rgba(0, 234, 255, 0.32); +} + +.cyber-hero__description { + max-width: 560px; + margin: 0 0 30px; + color: rgba(222, 229, 255, 0.84); + font-size: clamp(1rem, 1.08vw, 1.22rem); + line-height: 1.7; +} + +.cyber-hero__actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 18px; +} + +.cyber-hero__action.v-btn { + min-height: 52px !important; + min-width: 148px !important; + border-radius: var(--cyber-radius-sm) !important; + padding-inline: 18px !important; + font-weight: 800 !important; + font-size: 0.9rem !important; + letter-spacing: 0.01em !important; + text-transform: uppercase !important; + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + border-color 0.18s ease, + background 0.18s ease !important; +} + +.cyber-hero__action.v-btn:hover { + transform: translateY(-1px); +} + +.cyber-hero__action--primary.v-btn { + color: var(--cyber-bg-0) !important; + background: linear-gradient(135deg, var(--cyber-cyan), var(--cyber-magenta)) !important; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.16) inset, + 0 0 24px rgba(0, 234, 255, 0.34), + 0 0 34px rgba(255, 43, 255, 0.22) !important; +} + +.cyber-hero__action--watch.v-btn, +.cyber-hero__action--docs.v-btn { + color: var(--cyber-text) !important; + border-color: rgba(0, 234, 255, 0.46) !important; + background: rgba(3, 10, 22, 0.56) !important; +} + +.cyber-hero__action--watch.v-btn:hover, +.cyber-hero__action--docs.v-btn:hover { + color: var(--cyber-cyan) !important; + border-color: rgba(0, 234, 255, 0.74) !important; + background: rgba(0, 234, 255, 0.08) !important; +} + +.cyber-hero__terminal-note { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: min(100%, 560px); + min-height: 66px; + padding: 13px 18px; + color: var(--cyber-cyan); + text-decoration: none; + font-family: var(--at-font-mono); + font-size: 0.74rem; + line-height: 1.55; + transition: + border-color 0.18s ease, + color 0.18s ease, + box-shadow 0.18s ease; +} + +.cyber-hero__terminal-note:hover { + color: var(--cyber-amber); + border-color: rgba(255, 178, 56, 0.58); + box-shadow: + 0 0 0 1px rgba(255, 178, 56, 0.14) inset, + 0 0 28px rgba(255, 178, 56, 0.16); +} + +.cyber-hero__terminal-lines { + display: grid; + gap: 2px; +} + +.cyber-hero__release { + flex: 0 0 auto; + color: rgba(244, 247, 255, 0.62); + white-space: nowrap; +} + +.cyber-hero__scene { + min-width: 0; + max-width: 1120px; + margin-left: clamp(-220px, -12vw, -130px); + margin-top: -68px; + margin-right: 0; +} + +.cyber-scene { + position: relative; + isolation: isolate; + aspect-ratio: 16 / 9; + min-height: 600px; + transform: + translate3d( + calc(var(--hero-pointer-x) * 12px), + calc(var(--hero-pointer-y) * 8px), + 0 + ) + rotateX(calc(var(--hero-tilt-y) * 0.65deg)) + rotateY(calc(var(--hero-tilt-x) * -0.9deg)); + transform-style: preserve-3d; + will-change: transform; +} + +.cyber-scene__floor, +.cyber-scene__connectors, +.cyber-scene__robots, +.cyber-scene__messages, +.cyber-scene__foreground { + position: absolute; + inset: 0; + pointer-events: none; +} + +.cyber-scene__floor { + z-index: 0; + background: + radial-gradient(ellipse at 58% 84%, rgba(255, 43, 255, 0.38), transparent 20%), + radial-gradient(ellipse at 56% 84%, rgba(0, 234, 255, 0.27), transparent 36%), + repeating-radial-gradient(ellipse at 58% 84%, rgba(0, 234, 255, 0.2) 0 1px, transparent 1px 18px); + filter: blur(7px); + opacity: 0.95; +} + +.cyber-scene__connectors { + z-index: 1; +} + +.cyber-scene__video { + position: absolute; + z-index: 3; + left: 24%; + top: 27%; + width: 59%; + transform: + translate3d( + calc(var(--hero-pointer-x) * 8px), + calc(var(--hero-pointer-y) * 5px), + 0 + ); + pointer-events: auto; +} + +.cyber-scene__robots { + z-index: 4; +} + +.cyber-scene__messages { + z-index: 5; +} + +.cyber-scene__foreground { + z-index: 6; + background: + linear-gradient(90deg, transparent 0 4%, rgba(0, 234, 255, 0.08) 4.1%, transparent 4.4%), + linear-gradient(180deg, transparent 0 88%, rgba(255, 43, 255, 0.08)); + mix-blend-mode: screen; +} + +.cyber-video-frame { + --cyber-frame-cut: 20px; + + position: relative; + aspect-ratio: 16 / 9; + border: 1px solid rgba(0, 234, 255, 0.66); + background: rgba(2, 6, 16, 0.82); + clip-path: polygon(20px 0, 100% 0, 100% calc(100% - 20px), calc(100% - 20px) 100%, 0 100%, 0 20px); + box-shadow: + 0 0 0 1px rgba(47, 125, 255, 0.2) inset, + 0 0 34px rgba(0, 234, 255, 0.22), + 0 0 72px rgba(255, 43, 255, 0.12); +} + +.cyber-video-frame__bezel, +.cyber-video-frame__corner, +.cyber-video-frame__status { + pointer-events: none; +} + +.cyber-video-frame__bezel { + position: absolute; + inset: -12px; + border: 1px solid rgba(139, 92, 255, 0.34); + clip-path: polygon(28px 0, 100% 0, 100% calc(100% - 28px), calc(100% - 28px) 100%, 0 100%, 0 28px); + opacity: 0.8; +} + +.cyber-video-frame__status { + position: absolute; + z-index: 5; + top: 8px; + left: 12px; + right: 12px; + display: flex; + justify-content: space-between; + color: rgba(0, 234, 255, 0.72); + font-family: var(--at-font-mono); + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.cyber-video-frame__content { + position: absolute; + inset: 14px; + z-index: 2; + overflow: hidden; + border-radius: var(--cyber-radius-sm); + background: rgba(2, 6, 16, 0.94); + clip-path: polygon(14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%, 0 14px); +} + +.cyber-video-frame__content .hero-video { + width: 100%; + height: 100%; + border: 0; + border-radius: 0; + box-shadow: none; + background: rgba(2, 6, 16, 0.95); +} + +.cyber-video-frame__content .hero-video__player { + height: 100%; + object-fit: cover; + border-radius: 0; +} + +.cyber-video-frame__fallback { + width: 100%; + height: 100%; + min-height: 260px; + background: + linear-gradient(135deg, rgba(0, 234, 255, 0.08), rgba(255, 43, 255, 0.05)), + rgba(2, 6, 16, 0.94); +} + +.cyber-video-frame__corner { + position: absolute; + z-index: 6; + width: 34px; + height: 34px; + border-color: var(--cyber-cyan); + opacity: 0.9; +} + +.cyber-video-frame__corner--tl { + top: -1px; + left: -1px; + border-top: 2px solid; + border-left: 2px solid; +} + +.cyber-video-frame__corner--tr { + top: -1px; + right: -1px; + border-top: 2px solid; + border-right: 2px solid; +} + +.cyber-video-frame__corner--bl { + bottom: -1px; + left: -1px; + border-bottom: 2px solid; + border-left: 2px solid; +} + +.cyber-video-frame__corner--br { + right: -1px; + bottom: -1px; + border-right: 2px solid; + border-bottom: 2px solid; +} + +.cyber-agent { + --agent-accent: var(--cyber-cyan); + --agent-accent-soft: rgba(0, 234, 255, 0.22); + + position: absolute; + left: calc(var(--agent-x) * 1%); + top: calc(var(--agent-y) * 1%); + width: 168px; + transform: + translate3d(-50%, -50%, 0) + translate3d( + calc(var(--hero-pointer-x) * var(--agent-depth) * 18px), + calc(var(--hero-pointer-y) * var(--agent-depth) * 14px), + 0 + ) + scale(var(--agent-scale)); + transform-origin: center bottom; + transition: filter 0.2s ease; + will-change: transform; +} + +.cyber-agent--magenta, +.cyber-agent--violet { + --agent-accent: var(--cyber-magenta); + --agent-accent-soft: rgba(255, 43, 255, 0.24); +} + +.cyber-agent--amber { + --agent-accent: var(--cyber-amber); + --agent-accent-soft: rgba(255, 178, 56, 0.24); +} + +.cyber-agent--red { + --agent-accent: var(--cyber-red); + --agent-accent-soft: rgba(255, 76, 106, 0.24); +} + +.cyber-agent__float { + position: relative; + animation: cyberRobotBob calc(5.2s + var(--agent-depth) * 1.8s) ease-in-out infinite; + animation-delay: calc(var(--agent-depth) * -2.4s); +} + +.cyber-agent__contact { + position: absolute; + left: 18%; + right: 18%; + bottom: -8px; + height: 22px; + border-radius: 50%; + background: var(--agent-accent-soft); + filter: blur(10px); +} + +.cyber-agent__image { + position: relative; + z-index: 1; + display: block; + width: 100%; + height: auto; + user-select: none; + filter: + drop-shadow(0 14px 24px rgba(0, 0, 0, 0.54)) + drop-shadow(0 0 16px var(--agent-accent-soft)); +} + +.cyber-agent__eyes { + position: absolute; + z-index: 2; + top: 19%; + left: 37%; + width: 28%; + height: 10%; + border-radius: 999px; + background: var(--agent-accent-soft); + filter: blur(12px); + opacity: 0.72; + animation: cyberEyeBlink 5.8s ease-in-out infinite; + pointer-events: none; +} + +.cyber-agent__card { + position: absolute; + z-index: 4; + top: 20%; + left: 72%; + width: 190px; + padding: 11px 13px; + color: rgba(244, 247, 255, 0.84); + font-family: var(--at-font-mono); + font-size: 0.62rem; + line-height: 1.45; + border-color: color-mix(in srgb, var(--agent-accent), transparent 44%); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.06) inset, + 0 0 24px var(--agent-accent-soft); +} + +.cyber-agent--card-left .cyber-agent__card { + right: 72%; + left: auto; +} + +.cyber-agent--card-bottom .cyber-agent__card { + top: 78%; + left: 50%; + transform: translateX(-50%); +} + +.cyber-agent__label { + margin-bottom: 6px; + color: var(--agent-accent); + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.cyber-agent__tasks { + display: grid; + gap: 3px; + margin: 0; + padding: 0; + list-style: none; +} + +.cyber-agent__tasks li { + position: relative; + padding-left: 12px; + color: rgba(222, 229, 255, 0.72); +} + +.cyber-agent__tasks li::before { + content: ""; + position: absolute; + left: 0; + top: 0.52em; + width: 5px; + height: 5px; + border: 1px solid var(--agent-accent); + transform: rotate(45deg); +} + +.cyber-agent__status { + display: flex; + gap: 5px; + margin-top: 7px; + color: rgba(222, 229, 255, 0.62); +} + +.cyber-agent__status strong { + color: var(--agent-accent); + font-weight: 800; +} + +.cyber-agent--sending, +.cyber-agent--receiving { + filter: brightness(1.18); +} + +.cyber-agent--sending .cyber-agent__card, +.cyber-agent--receiving .cyber-agent__card { + border-color: var(--agent-accent); + animation: cyberPanelPulse 1.3s ease-in-out infinite; +} + +.cyber-connectors { + display: block; + width: 100%; + height: 100%; + overflow: visible; +} + +.cyber-connectors__path, +.cyber-connectors__path-glow { + fill: none; +} + +.cyber-connectors__path { + stroke: rgba(0, 234, 255, 0.36); + stroke-width: 1.2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.cyber-connectors__path-glow { + stroke: rgba(0, 234, 255, 0.14); + stroke-width: 6; + stroke-linecap: round; + stroke-linejoin: round; +} + +.cyber-connectors__path--magenta { + stroke: rgba(255, 43, 255, 0.34); +} + +.cyber-connectors__path-glow--magenta { + stroke: rgba(255, 43, 255, 0.16); +} + +.cyber-connectors__path--amber { + stroke: rgba(255, 178, 56, 0.34); +} + +.cyber-connectors__path-glow--amber { + stroke: rgba(255, 178, 56, 0.16); +} + +.cyber-connectors__path--active { + stroke: rgba(255, 43, 255, 0.92); + stroke-width: 1.8; +} + +.cyber-connectors__path-glow--active { + stroke: rgba(255, 43, 255, 0.38); + stroke-width: 9; +} + +.cyber-connectors__packet { + fill: var(--cyber-cyan); + filter: drop-shadow(0 0 8px rgba(0, 234, 255, 0.86)); + opacity: 0.74; +} + +.cyber-connectors__packet--magenta { + fill: var(--cyber-magenta); + filter: drop-shadow(0 0 8px rgba(255, 43, 255, 0.86)); +} + +.cyber-connectors__packet--amber { + fill: var(--cyber-amber); + filter: drop-shadow(0 0 8px rgba(255, 178, 56, 0.86)); +} + +.cyber-connectors__packet--active { + r: 5; + opacity: 1; +} + +.cyber-message { + position: absolute; + left: calc(var(--bubble-x) * 1%); + top: calc(var(--bubble-y) * 1%); + max-width: 196px; + padding: 10px 13px; + transform: translate3d(-50%, -50%, 0); + color: var(--cyber-cyan); + font-family: var(--at-font-mono); + font-size: 0.72rem; + line-height: 1.35; + text-shadow: 0 0 12px rgba(0, 234, 255, 0.32); +} + +.cyber-message--receiver { + color: var(--cyber-amber); + border-color: rgba(255, 178, 56, 0.5); +} + +.cyber-message--static { + left: 50%; + top: 78%; +} + +.cyber-bubble-enter-active, +.cyber-bubble-leave-active { + transition: + opacity 0.24s ease, + transform 0.24s ease; +} + +.cyber-bubble-enter-from, +.cyber-bubble-leave-to { + opacity: 0; + transform: translate3d(-50%, calc(-50% + 10px), 0); +} + +.cyber-feature-rail { + position: relative; + z-index: 7; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0; + margin: 28px auto 0; + width: min(1540px, 96%); + padding: 17px 18px; +} + +.cyber-feature-rail__item { + display: grid; + grid-template-columns: 46px minmax(0, 1fr); + align-items: center; + gap: 12px; + min-width: 0; + padding: 0 18px; + border-right: 1px solid rgba(0, 234, 255, 0.16); +} + +.cyber-feature-rail__item:last-child { + border-right: 0; +} + +.cyber-feature-rail__icon { + display: grid; + place-items: center; + width: 46px; + height: 46px; + color: var(--cyber-cyan); + border: 1px solid rgba(0, 234, 255, 0.44); + border-radius: var(--cyber-radius-sm); + box-shadow: 0 0 22px rgba(0, 234, 255, 0.16); +} + +.cyber-feature-rail__title { + margin-bottom: 3px; + color: rgba(244, 247, 255, 0.94); + font-weight: 800; + font-size: 0.92rem; +} + +.cyber-feature-rail__text { + color: rgba(222, 229, 255, 0.62); + font-size: 0.8rem; + line-height: 1.45; +} + +@keyframes cyberRobotBob { + 0%, + 100% { + transform: translate3d(0, 0, 0); + } + 50% { + transform: translate3d(0, -6px, 0); + } +} + +@keyframes cyberEyeBlink { + 0%, + 88%, + 100% { + opacity: 0.72; + transform: scaleY(1); + } + 91% { + opacity: 0.36; + transform: scaleY(0.24); + } + 94% { + opacity: 0.82; + transform: scaleY(1); + } +} + +@keyframes cyberPanelPulse { + 0%, + 100% { + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.06) inset, + 0 0 20px var(--agent-accent-soft); + } + 50% { + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.1) inset, + 0 0 36px var(--agent-accent-soft); + } +} + +@media (max-width: 1280px) { + .cyber-hero__layout { + grid-template-columns: minmax(380px, 0.74fr) minmax(0, 1.26fr); + gap: 20px; + } + + .cyber-hero__title { + font-size: clamp(3.35rem, 5.2vw, 5.6rem); + } + + .cyber-scene { + min-height: 560px; + } + + .cyber-agent__card { + width: 158px; + font-size: 0.56rem; + } +} + +@media (max-width: 1100px) { + .cyber-hero { + min-height: auto; + padding-top: 92px; + } + + .cyber-hero__layout { + grid-template-columns: 1fr; + } + + .cyber-hero__copy { + width: 100%; + max-width: 720px; + } + + .cyber-scene { + min-height: 620px; + max-width: 960px; + margin: 0 auto; + } + + .cyber-agent { + left: calc(var(--agent-tablet-x) * 1%); + top: calc(var(--agent-tablet-y) * 1%); + transform: + translate3d(-50%, -50%, 0) + translate3d( + calc(var(--hero-pointer-x) * var(--agent-tablet-depth) * 14px), + calc(var(--hero-pointer-y) * var(--agent-tablet-depth) * 10px), + 0 + ) + scale(var(--agent-tablet-scale)); + } + + .cyber-agent__card { + display: none; + } + + .cyber-feature-rail { + grid-template-columns: repeat(3, minmax(0, 1fr)); + width: 100%; + } + + .cyber-feature-rail__item:nth-child(3) { + border-right: 0; + } + + .cyber-feature-rail__item:nth-child(n + 4) { + margin-top: 16px; + } +} + +@media (max-width: 767px) { + .cyber-hero { + padding: 84px 0 36px; + } + + .cyber-hero__background { + background-image: url("~/assets/images/hero/backgrounds/cyber-city-mobile-v1.webp"); + background-position: center top; + opacity: 0.76; + transform: scale(1.02); + } + + .cyber-hero__container { + width: min(100% - 32px, 680px); + } + + .cyber-hero__brand-lockup { + margin-bottom: 28px; + } + + .cyber-hero__logo { + width: 42px; + height: 42px; + border-radius: 12px; + } + + .cyber-hero__title { + font-size: clamp(2.55rem, 13vw, 4rem); + } + + .cyber-hero__slogan { + display: flex; + width: 100%; + padding: 10px 14px; + letter-spacing: 0.04em; + } + + .cyber-hero__description { + font-size: 1rem; + line-height: 1.62; + } + + .cyber-hero__actions { + display: grid; + grid-template-columns: 1fr; + } + + .cyber-hero__action.v-btn { + width: 100%; + min-width: 0 !important; + } + + .cyber-hero__terminal-note { + display: grid; + gap: 8px; + font-size: 0.68rem; + } + + .cyber-scene { + min-height: auto; + aspect-ratio: auto; + padding: 90px 0 12px; + transform: none; + } + + .cyber-scene__floor, + .cyber-scene__connectors, + .cyber-scene__foreground { + display: none; + } + + .cyber-scene__video { + position: relative; + left: auto; + top: auto; + width: 100%; + transform: none; + } + + .cyber-scene__robots { + inset: 0 0 auto; + height: 92px; + display: flex; + justify-content: center; + gap: 10px; + } + + .cyber-agent { + position: relative; + display: none; + left: auto; + top: auto; + width: 76px; + transform: none; + } + + .cyber-agent--mobile-visible { + display: block; + } + + .cyber-agent__float { + animation-duration: 6s; + } + + .cyber-agent__card, + .cyber-agent__eyes { + display: none; + } + + .cyber-message { + display: none; + } + + .cyber-feature-rail { + grid-template-columns: 1fr; + padding: 16px; + } + + .cyber-feature-rail__item { + grid-template-columns: 42px minmax(0, 1fr); + padding: 12px 0; + border-right: 0; + border-bottom: 1px solid rgba(0, 234, 255, 0.14); + } + + .cyber-feature-rail__item:last-child { + border-bottom: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .cyber-hero *, + .cyber-hero *::before, + .cyber-hero *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } + + .cyber-hero__background, + .cyber-scene, + .cyber-scene__video, + .cyber-agent { + transform: none !important; + } +} diff --git a/landing/assets/styles/main.scss b/landing/assets/styles/main.scss index 85428476..ccf7bd60 100644 --- a/landing/assets/styles/main.scss +++ b/landing/assets/styles/main.scss @@ -1,3 +1,5 @@ +@use "./cyberpunk-hero"; + @import "./brand-tokens.css"; :root { diff --git a/landing/components/common/AppLogo.vue b/landing/components/common/AppLogo.vue index fb902dd1..41df1147 100644 --- a/landing/components/common/AppLogo.vue +++ b/landing/components/common/AppLogo.vue @@ -10,7 +10,7 @@ const { baseURL } = useRuntimeConfig().app; class="app-logo__img" width="36" height="36" - /> + > Agent Teams diff --git a/landing/components/hero/CyberHeroConnectors.vue b/landing/components/hero/CyberHeroConnectors.vue new file mode 100644 index 00000000..a964f0e6 --- /dev/null +++ b/landing/components/hero/CyberHeroConnectors.vue @@ -0,0 +1,62 @@ + + + diff --git a/landing/components/hero/CyberHeroFeatureStrip.vue b/landing/components/hero/CyberHeroFeatureStrip.vue new file mode 100644 index 00000000..02a1224c --- /dev/null +++ b/landing/components/hero/CyberHeroFeatureStrip.vue @@ -0,0 +1,36 @@ + + + diff --git a/landing/components/hero/CyberHeroMessageBubbles.vue b/landing/components/hero/CyberHeroMessageBubbles.vue new file mode 100644 index 00000000..a4738d3b --- /dev/null +++ b/landing/components/hero/CyberHeroMessageBubbles.vue @@ -0,0 +1,50 @@ + + + diff --git a/landing/components/hero/CyberHeroRobot.vue b/landing/components/hero/CyberHeroRobot.vue new file mode 100644 index 00000000..d6f33f08 --- /dev/null +++ b/landing/components/hero/CyberHeroRobot.vue @@ -0,0 +1,68 @@ + + + diff --git a/landing/components/hero/CyberHeroScene.vue b/landing/components/hero/CyberHeroScene.vue new file mode 100644 index 00000000..25ef353c --- /dev/null +++ b/landing/components/hero/CyberHeroScene.vue @@ -0,0 +1,131 @@ + + + diff --git a/landing/components/hero/CyberHeroVideoFrame.vue b/landing/components/hero/CyberHeroVideoFrame.vue new file mode 100644 index 00000000..e54ba812 --- /dev/null +++ b/landing/components/hero/CyberHeroVideoFrame.vue @@ -0,0 +1,31 @@ + diff --git a/landing/components/layout/AppHeader.vue b/landing/components/layout/AppHeader.vue index 16e3a4eb..0bfb8627 100644 --- a/landing/components/layout/AppHeader.vue +++ b/landing/components/layout/AppHeader.vue @@ -22,7 +22,9 @@ const navItems = computed(() => [