feat(setup): add onboarding live checks and first-success guidance
This commit is contained in:
@@ -89,7 +89,10 @@ Flynn provides a full CLI via the `flynn` binary (or `npx tsx src/cli/index.ts`
|
||||
| `flynn skills` | List/install/manage skills |
|
||||
| `flynn companion` | Run a minimal companion node client against the gateway |
|
||||
|
||||
`flynn setup` / `flynn onboard` now print a post-save channel verification checklist (start command, WebChat URL, `/status` smoke test, and channel-specific validation hints).
|
||||
`flynn setup` / `flynn onboard` now include:
|
||||
- a **Personal Assistant Mode** first-run preset (announce delivery, proactive memory, talk mode defaults, TTS fallback policy),
|
||||
- post-save **live readiness checks** (model, channel, memory, automation),
|
||||
- and a guided **first-success task** sequence after config save.
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -1288,6 +1291,7 @@ Repeated failure/recovery notifications are throttled by `notify_cooldown`.
|
||||
- `automation.minio_sync.notify.channel: webchat`
|
||||
|
||||
`flynn setup` now includes an Operator Pack option in Automation that preconfigures scheduled backups, heartbeat alerts, a daily briefing, and a default MinIO sync task, with prompts for output channel/peer routing.
|
||||
After save, setup prints focused live readiness checks and a first-success task path so new installs can validate end-to-end operation immediately.
|
||||
See `docs/operations/OPERATOR_PACK.md` for an operations runbook and verification checklist.
|
||||
|
||||
Example Operator Pack output routing:
|
||||
|
||||
@@ -23,6 +23,8 @@ The gateway provides:
|
||||
- **HTTP Server**: Serves static dashboard and handles webhook endpoints
|
||||
- **Node Capability Negotiation**: Optional companion-node role/capability registration
|
||||
|
||||
Operational note: onboarding (`flynn setup` / `flynn onboard`) now runs post-save live readiness checks (model/channel/memory/automation) and prints a guided first-success task flow. This improves setup reliability without changing JSON-RPC method/event shapes.
|
||||
|
||||
### Execution Model (Sessions + Per-Session Queue)
|
||||
|
||||
Two concepts matter for correct clients:
|
||||
|
||||
@@ -158,6 +158,7 @@ Gateway streaming UX signals:
|
||||
- Canvas artifacts are persisted by the gateway so session UI surfaces can recover after daemon restarts.
|
||||
- TTS synthesis uses an ordered provider chain with health cooldown tracking; if all providers fail, replies degrade to text-only without dropping the response.
|
||||
- Talk mode accepts spoken/text `stop`/`cancel` while active and maps it onto the same `/stop` run-control cancellation path used for text sessions.
|
||||
- Setup/onboarding now applies a first-run Personal Assistant Mode preset, prints model/channel/memory/automation live readiness checks, and emits a guided first-success task path after config save.
|
||||
|
||||
Key files:
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ If you only want the protocol surface, see `docs/api/PROTOCOL.md`.
|
||||
- Canvas artifacts are persisted per session under the gateway data directory for UI recovery across restarts.
|
||||
- TTS output is best-effort with ordered provider fallback + per-provider cooldown tracking; synthesis failures still fall back to text-only responses.
|
||||
- Talk mode voice sessions share the same cancel/replace semantics as text lanes (`/stop`, interrupt mode preemption), including spoken `stop`/`cancel` mapping while talk mode is active.
|
||||
- Setup/onboarding UX now adds post-save live readiness checks (model/channel/memory/automation) and a guided first-success task flow, improving zero-to-first-automation path reliability before sustained gateway use.
|
||||
|
||||
## Component Map
|
||||
|
||||
|
||||
+27
-4
@@ -6789,7 +6789,7 @@
|
||||
"status": "in_progress",
|
||||
"date": "2026-02-26",
|
||||
"updated": "2026-02-27",
|
||||
"summary": "Rebaselined Flynn's OpenClaw-style personal-assistant gaps and defined an execution-ready 8-10 week roadmap. Phase 3 browser reliability, Phase 1 companion reconnect/handoff reliability, and Phase 2 voice daily-driver reliability (talk controls + TTS provider fallback/health + interruption-safe voice cancel semantics) are now shipped.",
|
||||
"summary": "Rebaselined Flynn's OpenClaw-style personal-assistant gaps and defined an execution-ready 8-10 week roadmap. Phase 3 browser reliability, Phase 1 companion reconnect/handoff reliability, Phase 2 voice daily-driver reliability (talk controls + TTS provider fallback/health + interruption-safe voice cancel semantics), and Phase 4 onboarding first-success funnel improvements are now shipped.",
|
||||
"files_modified": [
|
||||
"docs/plans/2026-02-26-personal-assistant-productization-plan.md",
|
||||
"docs/plans/state.json"
|
||||
@@ -6868,6 +6868,29 @@
|
||||
],
|
||||
"test_status": "pnpm test:run src/models/tts.test.ts src/daemon/routing.test.ts src/gateway/handlers/handlers.test.ts src/gateway/ui/pages/settings.test.ts src/gateway/ui/pages/dashboard.test.ts src/gateway/ui/pages/chat.test.ts src/config/schema.test.ts + pnpm typecheck passing"
|
||||
},
|
||||
"personal-assistant-productization-phase4-onboarding-first-success": {
|
||||
"status": "completed",
|
||||
"date": "2026-02-27",
|
||||
"updated": "2026-02-27",
|
||||
"summary": "Implemented Phase 4 onboarding 2.0 first-success slice: setup/onboard first-run flow now offers a Personal Assistant Mode preset, runs live readiness checks (model/channel/memory/automation) after save, and prints a guided first-success task sequence to validate end-to-end operation quickly.",
|
||||
"files_modified": [
|
||||
"src/cli/setup/config.ts",
|
||||
"src/cli/setup/config.test.ts",
|
||||
"src/cli/setup/orchestrator.ts",
|
||||
"src/cli/setup/integration.test.ts",
|
||||
"src/cli/setup/liveChecks.ts",
|
||||
"src/cli/setup/liveChecks.test.ts",
|
||||
"src/cli/setup/summary.ts",
|
||||
"src/cli/setup/summary.test.ts",
|
||||
"src/cli/setup.ts",
|
||||
"README.md",
|
||||
"docs/api/PROTOCOL.md",
|
||||
"docs/architecture/AGENT_DIAGRAM.md",
|
||||
"docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md",
|
||||
"docs/plans/state.json"
|
||||
],
|
||||
"test_status": "pnpm test:run src/cli/setup/config.test.ts src/cli/setup/integration.test.ts src/cli/setup/summary.test.ts src/cli/setup/liveChecks.test.ts src/cli/setup/orchestrator.test.ts src/cli/setup/sections.test.ts + pnpm typecheck passing"
|
||||
},
|
||||
"subagents-support-phase1": {
|
||||
"status": "completed",
|
||||
"date": "2026-02-26",
|
||||
@@ -6902,7 +6925,7 @@
|
||||
}
|
||||
},
|
||||
"overall_progress": {
|
||||
"total_test_count": 2559,
|
||||
"total_test_count": 2568,
|
||||
"all_tests_passing": true,
|
||||
"p0_completion": "3/3 (100%)",
|
||||
"p1_completion": "4/4 (100%)",
|
||||
@@ -6917,7 +6940,7 @@
|
||||
"tier2_completion": "4/4 (100%) \u2014 inbound webhooks, vector memory search, Dockerfile, heartbeat monitor",
|
||||
"tier3_completion": "5/5 (100%) \u2014 lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings",
|
||||
"tier4_completion": "4/4 (100%) \u2014 gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes",
|
||||
"feature_gap_scorecard": "rebaselined 2026-02-26 and updated 2026-02-27 (phase 3 + phase 1 + phase 2 reliability slices) — channel breadth, setup wizard, baseline browser automation, subagent controls, browser workflow reliability primitives (wait/assert/extract/retries/checkpoints/guardrails/budgets), companion reconnect/runtime-handoff foundations, and voice reliability hardening (talk controls + TTS fallback/health + interruption-safe cancel semantics) are implemented; remaining high-impact personal-assistant gaps center on shipped desktop/mobile companion apps and first-success onboarding funnel optimization.",
|
||||
"feature_gap_scorecard": "rebaselined 2026-02-26 and updated 2026-02-27 (phase 3 + phase 1 + phase 2 + phase 4 slices) — channel breadth, setup wizard, baseline browser automation, subagent controls, browser workflow reliability primitives (wait/assert/extract/retries/checkpoints/guardrails/budgets), companion reconnect/runtime-handoff foundations, voice reliability hardening (talk controls + TTS fallback/health + interruption-safe cancel semantics), and onboarding first-success funnel improvements are implemented; remaining high-impact personal-assistant gaps center on shipped desktop/mobile companion app surfaces and packaging.",
|
||||
"operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete \u2014 milestone done",
|
||||
"dashboard_observability": "completed \u2014 service health graphs + core service log viewer added to web UI via observability RPCs and bounded backend sampling",
|
||||
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
||||
@@ -6950,7 +6973,7 @@
|
||||
"deeper_surfaces_phase3_companion_canvas_voice": "completed \u2014 companion reconnect resilience (auto-reconnect with backoff, pending-wait cancellation on disconnect), canvas artifact persistence (SQLite-backed store, daemon-restart durability), voice TTS fallback coverage (text-only reply on TTS failure, no dropped responses)",
|
||||
"deeper_surfaces_phase4_rollout": "completed \u2014 phase 4 rollout and operator readiness plan documented: canary rollout plan by feature flag/surface, explicit rollback playbook, operator docs and architecture/protocol docs synchronized",
|
||||
"post_phase_test_fixes": "completed \u2014 fixed 4 test failures introduced by phases 1-3: iOS/Android push listNodes (missing publishHeartbeat before platform-filtered query), server.test agent.send (run_state events now precede done; added sendAndWaitForDone helper), httpBody 413 (req.destroy() closed socket before response could be sent; replaced with Connection: close header on 413 responses)",
|
||||
"personal_assistant_productization_plan": "in_progress \u2014 8-10 week phased roadmap active; Phase 3 browser workflow reliability shipped, Phase 1 companion runtime reliability includes reconnect state replay + typed handoff support, and Phase 2 voice reliability now ships talk controls + TTS provider fallback/health + interruption-safe voice cancel mapping. Remaining phases: companion app packaging/surfaces and onboarding 2.0 first-success funnel.",
|
||||
"personal_assistant_productization_plan": "in_progress \u2014 8-10 week phased roadmap active; Phase 3 browser workflow reliability shipped, Phase 1 companion runtime reliability includes reconnect state replay + typed handoff support, Phase 2 voice reliability ships talk controls + TTS provider fallback/health + interruption-safe voice cancel mapping, and Phase 4 onboarding now includes Personal Assistant Mode preset + live readiness checks + first-success guidance. Remaining phase focus: companion app packaging/surfaces.",
|
||||
"subagents_support": "completed \u2014 subagent phases 1-3 shipped with `subagent.spawn/send/list/cancel/delete/summary`, per-child queue mode (`followup|interrupt`), budgets (`max_turns`, `max_total_tokens`, `turn_timeout_ms`), tool-profile overrides, trace-linked audit events, `/subagents` inspection commands, and focused regression tests."
|
||||
},
|
||||
"soul_md_and_cron_create": {
|
||||
|
||||
+28
-1
@@ -13,7 +13,8 @@ import {
|
||||
getMinioExtractorInstallHints,
|
||||
renderMinioExtractorSetupLines,
|
||||
} from './minioExtractors.js';
|
||||
import { renderOnboardingChecklist } from './setup/summary.js';
|
||||
import { renderFirstSuccessTask, renderOnboardingChecklist } from './setup/summary.js';
|
||||
import { countSetupLiveCheckStatuses, renderSetupLiveChecks, runSetupLiveChecks } from './setup/liveChecks.js';
|
||||
import type { SetupConfig } from './setup/config.js';
|
||||
|
||||
export async function runSetup(configPath: string): Promise<void> {
|
||||
@@ -30,6 +31,8 @@ export async function runSetup(configPath: string): Promise<void> {
|
||||
saveConfig(configPath, builder, p);
|
||||
const config = builder.build();
|
||||
printOnboardingChecklist(p, config as Record<string, unknown>, configPath);
|
||||
printSetupLiveChecks(p, config as Record<string, unknown>);
|
||||
printFirstSuccessTask(p, config as Record<string, unknown>);
|
||||
await printMinioExtractorSetupStatus(p, config as Record<string, unknown>);
|
||||
await runGoogleAuth(p, config);
|
||||
} else {
|
||||
@@ -38,6 +41,8 @@ export async function runSetup(configPath: string): Promise<void> {
|
||||
saveConfig(configPath, builder, p);
|
||||
const config = builder.build();
|
||||
printOnboardingChecklist(p, config as Record<string, unknown>, configPath);
|
||||
printSetupLiveChecks(p, config as Record<string, unknown>);
|
||||
printFirstSuccessTask(p, config as Record<string, unknown>);
|
||||
await printMinioExtractorSetupStatus(p, config as Record<string, unknown>);
|
||||
await runGoogleAuth(p, config);
|
||||
|
||||
@@ -62,6 +67,8 @@ export async function runSetup(configPath: string): Promise<void> {
|
||||
saveConfig(configPath, menuBuilder, p);
|
||||
const config = menuBuilder.build();
|
||||
printOnboardingChecklist(p, config as Record<string, unknown>, configPath);
|
||||
printSetupLiveChecks(p, config as Record<string, unknown>);
|
||||
printFirstSuccessTask(p, config as Record<string, unknown>);
|
||||
await printMinioExtractorSetupStatus(p, config as Record<string, unknown>);
|
||||
await runGoogleAuth(p, config);
|
||||
}
|
||||
@@ -88,6 +95,26 @@ function printOnboardingChecklist(
|
||||
p.println(renderOnboardingChecklist(config as SetupConfig, { configPath }));
|
||||
}
|
||||
|
||||
function printSetupLiveChecks(
|
||||
p: { println(msg?: string): void },
|
||||
config: Record<string, unknown>,
|
||||
): void {
|
||||
const results = runSetupLiveChecks(config as SetupConfig);
|
||||
const counts = countSetupLiveCheckStatuses(results);
|
||||
|
||||
p.println();
|
||||
p.println(renderSetupLiveChecks(results));
|
||||
p.println(` Summary: ${counts.pass} pass, ${counts.fail} fail, ${counts.warn} warn, ${counts.skip} skip`);
|
||||
}
|
||||
|
||||
function printFirstSuccessTask(
|
||||
p: { println(msg?: string): void },
|
||||
config: Record<string, unknown>,
|
||||
): void {
|
||||
p.println();
|
||||
p.println(renderFirstSuccessTask(config as SetupConfig));
|
||||
}
|
||||
|
||||
async function printMinioExtractorSetupStatus(
|
||||
p: { println(msg?: string): void },
|
||||
config: Record<string, unknown>,
|
||||
|
||||
@@ -127,4 +127,34 @@ describe('ConfigBuilder', () => {
|
||||
namespace: 'global',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies personal assistant mode defaults', () => {
|
||||
const builder = new ConfigBuilder();
|
||||
builder.applyPersonalAssistantMode();
|
||||
|
||||
const obj = builder.build();
|
||||
expect((obj.automation as Record<string, unknown>)?.delivery_mode).toBe('announce');
|
||||
expect((obj.memory as Record<string, unknown>)?.daily_log).toEqual({
|
||||
enabled: true,
|
||||
namespace_prefix: 'daily',
|
||||
});
|
||||
expect((obj.memory as Record<string, unknown>)?.proactive_extract).toEqual({
|
||||
enabled: true,
|
||||
min_tool_calls: 2,
|
||||
namespace: 'global',
|
||||
});
|
||||
expect((obj.audio as Record<string, unknown>)?.talk_mode).toMatchObject({
|
||||
enabled: true,
|
||||
wake_phrase: 'hey flynn',
|
||||
timeout_ms: 120000,
|
||||
allow_manual_toggle: true,
|
||||
});
|
||||
expect((obj.tts as Record<string, unknown>)?.fallback).toEqual({
|
||||
max_attempts: 3,
|
||||
failure_cooldown_ms: 60000,
|
||||
});
|
||||
expect((obj.server as Record<string, unknown>)?.queue).toMatchObject({
|
||||
mode: 'interrupt',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+76
-1
@@ -23,6 +23,9 @@ export interface SetupConfig {
|
||||
token?: string;
|
||||
lock?: boolean;
|
||||
tailscale?: { serve?: boolean };
|
||||
queue?: {
|
||||
mode?: 'collect' | 'followup' | 'steer' | 'steer_backlog' | 'interrupt';
|
||||
};
|
||||
} & Record<string, unknown>;
|
||||
hooks?: Record<string, unknown>;
|
||||
telegram?: { bot_token: string; allowed_chat_ids: number[] };
|
||||
@@ -30,6 +33,22 @@ export interface SetupConfig {
|
||||
slack?: { bot_token: string; app_token: string; signing_secret: string; allowed_channel_ids: string[] };
|
||||
whatsapp?: { allowed_numbers: string[] };
|
||||
memory?: { embedding?: { enabled?: boolean; provider?: string; api_key?: string; endpoint?: string } };
|
||||
audio?: {
|
||||
enabled?: boolean;
|
||||
talk_mode?: {
|
||||
enabled?: boolean;
|
||||
wake_phrase?: string;
|
||||
timeout_ms?: number;
|
||||
allow_manual_toggle?: boolean;
|
||||
};
|
||||
};
|
||||
tts?: {
|
||||
enabled?: boolean;
|
||||
fallback?: {
|
||||
max_attempts?: number;
|
||||
failure_cooldown_ms?: number;
|
||||
};
|
||||
};
|
||||
sandbox?: { enabled?: boolean };
|
||||
pairing?: { enabled?: boolean };
|
||||
tools?: { profile?: string };
|
||||
@@ -55,7 +74,15 @@ export interface SetupConfig {
|
||||
gdrive?: { enabled?: boolean };
|
||||
gtasks?: { enabled?: boolean };
|
||||
heartbeat?: { enabled?: boolean };
|
||||
daily_briefing?: { enabled?: boolean };
|
||||
daily_briefing?: {
|
||||
enabled?: boolean;
|
||||
schedule?: string;
|
||||
output?: {
|
||||
channel?: string;
|
||||
peer?: string;
|
||||
};
|
||||
model_tier?: 'fast' | 'default' | 'complex' | 'local';
|
||||
};
|
||||
minio_sync?: { enabled?: boolean };
|
||||
} & Record<string, unknown>;
|
||||
backup?: {
|
||||
@@ -79,6 +106,11 @@ interface ResearchAgentOptions {
|
||||
modelTier: 'fast' | 'default' | 'complex' | 'local';
|
||||
}
|
||||
|
||||
interface PersonalAssistantModeOptions {
|
||||
enableTalkMode?: boolean;
|
||||
enableTts?: boolean;
|
||||
}
|
||||
|
||||
export class ConfigBuilder {
|
||||
private config: SetupConfig;
|
||||
|
||||
@@ -317,6 +349,49 @@ export class ConfigBuilder {
|
||||
this.config.memory = memory;
|
||||
}
|
||||
|
||||
applyPersonalAssistantMode(options?: PersonalAssistantModeOptions): void {
|
||||
const automation = (this.config.automation ?? {}) as Record<string, unknown>;
|
||||
const memory = (this.config.memory ?? {}) as Record<string, unknown>;
|
||||
const audio = (this.config.audio ?? {}) as Record<string, unknown>;
|
||||
const talkMode = (audio.talk_mode ?? {}) as Record<string, unknown>;
|
||||
const tts = (this.config.tts ?? {}) as Record<string, unknown>;
|
||||
const ttsFallback = (tts.fallback ?? {}) as Record<string, unknown>;
|
||||
const server = (this.config.server ?? {}) as Record<string, unknown>;
|
||||
const queue = (server.queue ?? {}) as Record<string, unknown>;
|
||||
|
||||
automation.delivery_mode = 'announce';
|
||||
memory.daily_log = {
|
||||
enabled: true,
|
||||
namespace_prefix: 'daily',
|
||||
};
|
||||
memory.proactive_extract = {
|
||||
enabled: true,
|
||||
min_tool_calls: 2,
|
||||
namespace: 'global',
|
||||
};
|
||||
|
||||
talkMode.enabled = options?.enableTalkMode ?? true;
|
||||
talkMode.wake_phrase = 'hey flynn';
|
||||
talkMode.timeout_ms = 120000;
|
||||
talkMode.allow_manual_toggle = true;
|
||||
audio.talk_mode = talkMode;
|
||||
audio.enabled = Boolean(audio.enabled || talkMode.enabled);
|
||||
|
||||
tts.enabled = options?.enableTts ?? true;
|
||||
ttsFallback.max_attempts = 3;
|
||||
ttsFallback.failure_cooldown_ms = 60000;
|
||||
tts.fallback = ttsFallback;
|
||||
|
||||
queue.mode = 'interrupt';
|
||||
server.queue = queue;
|
||||
|
||||
this.config.automation = automation;
|
||||
this.config.memory = memory;
|
||||
this.config.audio = audio;
|
||||
this.config.tts = tts;
|
||||
this.config.server = server as SetupConfig['server'];
|
||||
}
|
||||
|
||||
build(): SetupConfig {
|
||||
return structuredClone(this.config) as SetupConfig;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('first-run wizard integration', () => {
|
||||
'n', // confirm: Configure a fast tier? (no)
|
||||
'', // ask: Gateway port (default)
|
||||
'n', // confirm: Add a messaging channel? (no)
|
||||
'n', // confirm: Enable Personal Assistant Mode defaults? (no)
|
||||
'n', // confirm: Configure automation now? (no)
|
||||
]);
|
||||
const p = createPrompter(rl);
|
||||
@@ -54,6 +55,7 @@ describe('first-run wizard integration', () => {
|
||||
'123:ABCdef', // password: Bot token
|
||||
'12345678', // ask: Allowed chat IDs
|
||||
'n', // confirm: Add another channel? (no)
|
||||
'n', // confirm: Enable Personal Assistant Mode defaults? (no)
|
||||
'n', // confirm: Configure automation now? (no)
|
||||
]);
|
||||
const p = createPrompter(rl);
|
||||
@@ -78,6 +80,7 @@ describe('first-run wizard integration', () => {
|
||||
'123:ABCdef', // password: Bot token
|
||||
'12345678', // ask: Allowed chat IDs
|
||||
'n', // confirm: Add another channel? (no)
|
||||
'y', // confirm: Enable Personal Assistant Mode defaults? (yes)
|
||||
'y', // confirm: Configure automation now? (yes)
|
||||
'y', // confirm: Enable operator automation pack? (yes)
|
||||
'', // ask: Operator notifications output channel (default)
|
||||
@@ -101,4 +104,31 @@ describe('first-run wizard integration', () => {
|
||||
expect(backup.schedule).toBe('0 2 * * *');
|
||||
expect(heartbeat.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('applies personal assistant mode defaults when accepted', async () => {
|
||||
const rl = mockReadline([
|
||||
'1', // choose: Anthropic
|
||||
'sk-ant-key', // password: API key
|
||||
'', // ask: Model (default)
|
||||
'n', // confirm: Configure a fast tier? (no)
|
||||
'', // ask: Gateway port (default)
|
||||
'n', // confirm: Add a messaging channel? (no)
|
||||
'', // confirm: Enable Personal Assistant Mode defaults? (yes, default)
|
||||
'n', // confirm: Configure automation now? (no)
|
||||
]);
|
||||
const p = createPrompter(rl);
|
||||
|
||||
const builder = await runFirstRunWizard(p);
|
||||
const config = builder.build() as Record<string, unknown>;
|
||||
const automation = config.automation as Record<string, unknown>;
|
||||
const memory = config.memory as Record<string, unknown>;
|
||||
const audio = config.audio as Record<string, unknown>;
|
||||
|
||||
expect(automation.delivery_mode).toBe('announce');
|
||||
expect(memory.daily_log).toEqual({
|
||||
enabled: true,
|
||||
namespace_prefix: 'daily',
|
||||
});
|
||||
expect((audio.talk_mode as Record<string, unknown>)?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { countSetupLiveCheckStatuses, renderSetupLiveChecks, runSetupLiveChecks } from './liveChecks.js';
|
||||
import type { SetupConfig } from './config.js';
|
||||
|
||||
function createBaseConfig(): SetupConfig {
|
||||
return {
|
||||
models: {
|
||||
default: {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1-mini',
|
||||
api_key: 'sk-test',
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 18800,
|
||||
localhost: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('setup live checks', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('returns pass/skip checks for a minimal healthy setup', () => {
|
||||
const results = runSetupLiveChecks(createBaseConfig());
|
||||
expect(results).toHaveLength(4);
|
||||
expect(results[0]).toMatchObject({ status: 'pass', label: 'Model connectivity' });
|
||||
expect(results[1]).toMatchObject({ status: 'pass', label: 'Channel connectivity' });
|
||||
expect(results[2]).toMatchObject({ status: 'skip', label: 'Memory readiness' });
|
||||
expect(results[3]).toMatchObject({ status: 'skip', label: 'Automation readiness' });
|
||||
});
|
||||
|
||||
it('fails model readiness when api-key provider has no credentials', () => {
|
||||
const config = createBaseConfig();
|
||||
config.models.default.api_key = undefined;
|
||||
|
||||
const results = runSetupLiveChecks(config);
|
||||
const model = results.find((entry) => entry.label === 'Model connectivity');
|
||||
expect(model).toMatchObject({ status: 'fail' });
|
||||
expect(String(model?.detail ?? '')).toContain('missing credentials');
|
||||
});
|
||||
|
||||
it('fails automation readiness when daily briefing output is missing', () => {
|
||||
const config = createBaseConfig();
|
||||
config.automation = {
|
||||
daily_briefing: {
|
||||
enabled: true,
|
||||
},
|
||||
} as SetupConfig['automation'];
|
||||
|
||||
const results = runSetupLiveChecks(config);
|
||||
const automation = results.find((entry) => entry.label === 'Automation readiness');
|
||||
expect(automation).toMatchObject({ status: 'fail' });
|
||||
expect(String(automation?.detail ?? '')).toContain('output.channel/output.peer');
|
||||
});
|
||||
|
||||
it('warns memory readiness for local embeddings without endpoint', () => {
|
||||
const config = createBaseConfig();
|
||||
config.memory = {
|
||||
embedding: {
|
||||
enabled: true,
|
||||
provider: 'ollama',
|
||||
},
|
||||
};
|
||||
|
||||
const results = runSetupLiveChecks(config);
|
||||
const memory = results.find((entry) => entry.label === 'Memory readiness');
|
||||
expect(memory).toMatchObject({ status: 'warn' });
|
||||
});
|
||||
|
||||
it('renders and counts check statuses', () => {
|
||||
const config = createBaseConfig();
|
||||
config.memory = {
|
||||
embedding: {
|
||||
enabled: true,
|
||||
provider: 'openai',
|
||||
api_key: 'sk-embed',
|
||||
},
|
||||
};
|
||||
config.automation = {
|
||||
daily_briefing: {
|
||||
enabled: true,
|
||||
output: { channel: 'telegram', peer: '123' },
|
||||
},
|
||||
} as SetupConfig['automation'];
|
||||
|
||||
const results = runSetupLiveChecks(config);
|
||||
const summary = countSetupLiveCheckStatuses(results);
|
||||
const rendered = renderSetupLiveChecks(results);
|
||||
|
||||
expect(summary.pass).toBeGreaterThanOrEqual(3);
|
||||
expect(summary.fail).toBe(0);
|
||||
expect(rendered).toContain('Setup live checks:');
|
||||
expect(rendered).toContain('[PASS] Model connectivity');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { SetupConfig } from './config.js';
|
||||
|
||||
export interface SetupLiveCheckResult {
|
||||
status: 'pass' | 'warn' | 'fail' | 'skip';
|
||||
label: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
const PROVIDER_ENV_KEY: Record<string, string> = {
|
||||
openai: 'OPENAI_API_KEY',
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
gemini: 'GEMINI_API_KEY',
|
||||
openrouter: 'OPENROUTER_API_KEY',
|
||||
vercel: 'AI_GATEWAY_API_KEY',
|
||||
zhipuai: 'ZHIPUAI_API_KEY',
|
||||
xai: 'XAI_API_KEY',
|
||||
minimax: 'MINIMAX_API_KEY',
|
||||
moonshot: 'MOONSHOT_API_KEY',
|
||||
voyage: 'VOYAGE_API_KEY',
|
||||
};
|
||||
|
||||
function hasNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function runModelReadinessCheck(config: SetupConfig): SetupLiveCheckResult {
|
||||
const defaultModel = config.models?.default;
|
||||
if (!defaultModel || !hasNonEmptyString(defaultModel.provider) || !hasNonEmptyString(defaultModel.model)) {
|
||||
return {
|
||||
status: 'fail',
|
||||
label: 'Model connectivity',
|
||||
detail: 'default model provider/model is missing',
|
||||
};
|
||||
}
|
||||
|
||||
const provider = defaultModel.provider;
|
||||
const modelName = defaultModel.model;
|
||||
const envVar = PROVIDER_ENV_KEY[provider];
|
||||
const envHasKey = envVar ? hasNonEmptyString(process.env[envVar]) : false;
|
||||
const configHasKey = hasNonEmptyString(defaultModel.api_key);
|
||||
|
||||
if (envVar && !envHasKey && !configHasKey) {
|
||||
return {
|
||||
status: 'fail',
|
||||
label: 'Model connectivity',
|
||||
detail: `${provider}/${modelName} missing credentials (set ${envVar} or configure api_key)`,
|
||||
};
|
||||
}
|
||||
|
||||
const keySource = configHasKey ? 'config' : envHasKey ? 'env' : 'n/a';
|
||||
return {
|
||||
status: 'pass',
|
||||
label: 'Model connectivity',
|
||||
detail: `${provider}/${modelName} (auth=${keySource})`,
|
||||
};
|
||||
}
|
||||
|
||||
function runChannelReadinessCheck(config: SetupConfig): SetupLiveCheckResult {
|
||||
const port = Number(config.server?.port ?? 18800);
|
||||
const issues: string[] = [];
|
||||
const enabledChannels: string[] = [];
|
||||
|
||||
if (config.telegram) {
|
||||
enabledChannels.push('telegram');
|
||||
if (!hasNonEmptyString(config.telegram.bot_token)) {
|
||||
issues.push('telegram.bot_token missing');
|
||||
}
|
||||
if (!Array.isArray(config.telegram.allowed_chat_ids) || config.telegram.allowed_chat_ids.length === 0) {
|
||||
issues.push('telegram.allowed_chat_ids missing');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.discord) {
|
||||
enabledChannels.push('discord');
|
||||
if (!hasNonEmptyString(config.discord.bot_token)) {
|
||||
issues.push('discord.bot_token missing');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.slack) {
|
||||
enabledChannels.push('slack');
|
||||
if (!hasNonEmptyString(config.slack.bot_token)) {
|
||||
issues.push('slack.bot_token missing');
|
||||
}
|
||||
if (!hasNonEmptyString(config.slack.app_token)) {
|
||||
issues.push('slack.app_token missing');
|
||||
}
|
||||
if (!hasNonEmptyString(config.slack.signing_secret)) {
|
||||
issues.push('slack.signing_secret missing');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.whatsapp) {
|
||||
enabledChannels.push('whatsapp');
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
return {
|
||||
status: 'fail',
|
||||
label: 'Channel connectivity',
|
||||
detail: issues.join('; '),
|
||||
};
|
||||
}
|
||||
|
||||
const channelSummary = enabledChannels.length > 0
|
||||
? enabledChannels.join(', ')
|
||||
: 'webchat';
|
||||
return {
|
||||
status: 'pass',
|
||||
label: 'Channel connectivity',
|
||||
detail: `${channelSummary} (webchat http://localhost:${port}/#/chat)`,
|
||||
};
|
||||
}
|
||||
|
||||
function runMemoryReadinessCheck(config: SetupConfig): SetupLiveCheckResult {
|
||||
const embedding = config.memory?.embedding;
|
||||
if (!embedding?.enabled) {
|
||||
return {
|
||||
status: 'skip',
|
||||
label: 'Memory readiness',
|
||||
detail: 'vector memory disabled (keyword memory still available)',
|
||||
};
|
||||
}
|
||||
|
||||
const provider = embedding.provider ?? 'openai';
|
||||
const envVar = PROVIDER_ENV_KEY[provider];
|
||||
const configHasKey = hasNonEmptyString(embedding.api_key);
|
||||
const envHasKey = envVar ? hasNonEmptyString(process.env[envVar]) : false;
|
||||
|
||||
if ((provider === 'openai' || provider === 'gemini' || provider === 'voyage') && !configHasKey && !envHasKey) {
|
||||
return {
|
||||
status: 'fail',
|
||||
label: 'Memory readiness',
|
||||
detail: `${provider} embeddings enabled but credentials are missing${envVar ? ` (set ${envVar} or embedding.api_key)` : ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
if ((provider === 'ollama' || provider === 'llamacpp') && !hasNonEmptyString(embedding.endpoint)) {
|
||||
return {
|
||||
status: 'warn',
|
||||
label: 'Memory readiness',
|
||||
detail: `${provider} embeddings enabled without endpoint (set memory.embedding.endpoint for reliability)`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'pass',
|
||||
label: 'Memory readiness',
|
||||
detail: `vector memory enabled (${provider})`,
|
||||
};
|
||||
}
|
||||
|
||||
function runAutomationReadinessCheck(config: SetupConfig): SetupLiveCheckResult {
|
||||
const automation = config.automation ?? {};
|
||||
const enabledFeatures: string[] = [];
|
||||
|
||||
if ((automation.daily_briefing as { enabled?: boolean } | undefined)?.enabled) {
|
||||
enabledFeatures.push('daily_briefing');
|
||||
}
|
||||
if ((automation.heartbeat as { enabled?: boolean } | undefined)?.enabled) {
|
||||
enabledFeatures.push('heartbeat');
|
||||
}
|
||||
if ((automation.gmail as { enabled?: boolean } | undefined)?.enabled) {
|
||||
enabledFeatures.push('gmail');
|
||||
}
|
||||
if ((automation.gcal as { enabled?: boolean } | undefined)?.enabled) {
|
||||
enabledFeatures.push('gcal');
|
||||
}
|
||||
if ((automation.gdocs as { enabled?: boolean } | undefined)?.enabled) {
|
||||
enabledFeatures.push('gdocs');
|
||||
}
|
||||
if ((automation.gdrive as { enabled?: boolean } | undefined)?.enabled) {
|
||||
enabledFeatures.push('gdrive');
|
||||
}
|
||||
if ((automation.gtasks as { enabled?: boolean } | undefined)?.enabled) {
|
||||
enabledFeatures.push('gtasks');
|
||||
}
|
||||
if ((automation.minio_sync as { enabled?: boolean } | undefined)?.enabled) {
|
||||
enabledFeatures.push('minio_sync');
|
||||
}
|
||||
if (Array.isArray(automation.cron) && automation.cron.length > 0) {
|
||||
enabledFeatures.push(`cron:${automation.cron.length}`);
|
||||
}
|
||||
if (Array.isArray(automation.webhooks) && automation.webhooks.length > 0) {
|
||||
enabledFeatures.push(`webhooks:${automation.webhooks.length}`);
|
||||
}
|
||||
if (config.backup?.enabled) {
|
||||
enabledFeatures.push('backup');
|
||||
}
|
||||
|
||||
if (enabledFeatures.length === 0) {
|
||||
return {
|
||||
status: 'skip',
|
||||
label: 'Automation readiness',
|
||||
detail: 'no automation enabled',
|
||||
};
|
||||
}
|
||||
|
||||
const dailyBriefing = automation.daily_briefing as {
|
||||
enabled?: boolean;
|
||||
output?: { channel?: string; peer?: string };
|
||||
} | undefined;
|
||||
if (dailyBriefing?.enabled) {
|
||||
const output = dailyBriefing.output;
|
||||
if (!hasNonEmptyString(output?.channel) || !hasNonEmptyString(output?.peer)) {
|
||||
return {
|
||||
status: 'fail',
|
||||
label: 'Automation readiness',
|
||||
detail: 'daily_briefing enabled but output.channel/output.peer are missing',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const backupEnabled = config.backup?.enabled;
|
||||
if (backupEnabled && !hasNonEmptyString(config.backup?.schedule)) {
|
||||
return {
|
||||
status: 'warn',
|
||||
label: 'Automation readiness',
|
||||
detail: `enabled features: ${enabledFeatures.join(', ')} (backup schedule missing)`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'pass',
|
||||
label: 'Automation readiness',
|
||||
detail: `enabled features: ${enabledFeatures.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function runSetupLiveChecks(config: SetupConfig): SetupLiveCheckResult[] {
|
||||
return [
|
||||
runModelReadinessCheck(config),
|
||||
runChannelReadinessCheck(config),
|
||||
runMemoryReadinessCheck(config),
|
||||
runAutomationReadinessCheck(config),
|
||||
];
|
||||
}
|
||||
|
||||
export function renderSetupLiveChecks(results: SetupLiveCheckResult[]): string {
|
||||
const lines: string[] = ['Setup live checks:'];
|
||||
const marker: Record<SetupLiveCheckResult['status'], string> = {
|
||||
pass: 'PASS',
|
||||
warn: 'WARN',
|
||||
fail: 'FAIL',
|
||||
skip: 'SKIP',
|
||||
};
|
||||
for (const result of results) {
|
||||
const detail = result.detail ? ` — ${result.detail}` : '';
|
||||
lines.push(` [${marker[result.status]}] ${result.label}${detail}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function countSetupLiveCheckStatuses(results: SetupLiveCheckResult[]): {
|
||||
pass: number;
|
||||
warn: number;
|
||||
fail: number;
|
||||
skip: number;
|
||||
} {
|
||||
return results.reduce(
|
||||
(acc, result) => {
|
||||
acc[result.status] += 1;
|
||||
return acc;
|
||||
},
|
||||
{ pass: 0, warn: 0, fail: 0, skip: 0 },
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,18 @@ export async function runFirstRunWizard(p: Prompter): Promise<ConfigBuilder> {
|
||||
p.println();
|
||||
await setupChannels(p, builder);
|
||||
|
||||
// Step 3: Optional automation pack
|
||||
// Step 3: Personal Assistant Mode defaults
|
||||
p.println();
|
||||
const enablePersonalAssistantMode = await p.confirm(
|
||||
'Enable Personal Assistant Mode defaults (announce delivery, proactive memory, talk mode, TTS fallback)?',
|
||||
true,
|
||||
);
|
||||
if (enablePersonalAssistantMode) {
|
||||
builder.applyPersonalAssistantMode();
|
||||
p.println('✓ Personal Assistant Mode defaults enabled');
|
||||
}
|
||||
|
||||
// Step 4: Optional automation pack
|
||||
p.println();
|
||||
const configureAutomation = await p.confirm('Configure automation now (operator pack, cron, webhooks, Google services)?', false);
|
||||
if (configureAutomation) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderOnboardingChecklist, renderOperatorPackStatus, renderSummary } from './summary.js';
|
||||
import { renderFirstSuccessTask, renderOnboardingChecklist, renderOperatorPackStatus, renderSummary } from './summary.js';
|
||||
import type { SetupConfig } from './config.js';
|
||||
|
||||
describe('renderSummary', () => {
|
||||
@@ -89,3 +89,30 @@ describe('renderOnboardingChecklist', () => {
|
||||
expect(output).toContain('Slack: message the bot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderFirstSuccessTask', () => {
|
||||
it('renders daily-briefing-first guidance when daily briefing is enabled', () => {
|
||||
const config = {
|
||||
server: { port: 18800, localhost: true },
|
||||
automation: {
|
||||
daily_briefing: {
|
||||
enabled: true,
|
||||
output: { channel: 'telegram', peer: '123' },
|
||||
},
|
||||
},
|
||||
} as unknown as SetupConfig;
|
||||
|
||||
const output = renderFirstSuccessTask(config);
|
||||
expect(output).toContain('Create my daily briefing now');
|
||||
expect(output).toContain('telegram/123');
|
||||
});
|
||||
|
||||
it('falls back to proposal guidance when no automation is enabled', () => {
|
||||
const config = {
|
||||
server: { port: 18800, localhost: true },
|
||||
} as unknown as SetupConfig;
|
||||
|
||||
const output = renderFirstSuccessTask(config);
|
||||
expect(output).toContain('Propose one useful automation');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,3 +112,44 @@ export function renderOnboardingChecklist(config: SetupConfig, opts?: { configPa
|
||||
lines.push(' 5. Run `flynn doctor` if any channel test fails');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function hasGoogleAutomationEnabled(config: SetupConfig): boolean {
|
||||
const automation = config.automation ?? {};
|
||||
return Boolean(
|
||||
automation.gmail?.enabled
|
||||
|| automation.gcal?.enabled
|
||||
|| automation.gdocs?.enabled
|
||||
|| automation.gdrive?.enabled
|
||||
|| automation.gtasks?.enabled,
|
||||
);
|
||||
}
|
||||
|
||||
export function renderFirstSuccessTask(config: SetupConfig): string {
|
||||
const lines: string[] = [];
|
||||
const port = config.server?.port ?? 18800;
|
||||
const automation = config.automation ?? {};
|
||||
const dailyBriefingEnabled = Boolean(automation.daily_briefing?.enabled);
|
||||
const dailyBriefingOutput = automation.daily_briefing?.output as
|
||||
| { channel?: string; peer?: string }
|
||||
| undefined;
|
||||
|
||||
lines.push('Guided first-success task (under 5 minutes):');
|
||||
lines.push(` 1. Open WebChat: http://localhost:${port}/#/chat`);
|
||||
lines.push(' 2. Send `/status` and confirm Flynn replies.');
|
||||
|
||||
if (dailyBriefingEnabled && dailyBriefingOutput?.channel && dailyBriefingOutput?.peer) {
|
||||
lines.push(' 3. Send: "Create my daily briefing now. Use available tools and output: Schedule, Priorities, Risks, First actions."');
|
||||
lines.push(` 4. Confirm the reply is actionable and routed for ${dailyBriefingOutput.channel}/${dailyBriefingOutput.peer}.`);
|
||||
} else if (hasGoogleAutomationEnabled(config)) {
|
||||
lines.push(' 3. Send: "Use my connected Google services to summarize today\'s schedule and top 3 priorities."');
|
||||
lines.push(' 4. Confirm at least one connected service signal appears in the reply.');
|
||||
} else if (Array.isArray(automation.cron) && automation.cron.length > 0) {
|
||||
lines.push(' 3. Send: "List my active automation jobs and recommend which one to run now."');
|
||||
lines.push(' 4. Confirm Flynn identifies at least one configured automation by name.');
|
||||
} else {
|
||||
lines.push(' 3. Send: "Propose one useful automation I can enable today with exact config fields and values."');
|
||||
lines.push(' 4. Confirm the reply includes channel + schedule/output details you can apply directly.');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user