stagent 0.9.5 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -42
- package/dist/cli.js +42 -18
- package/docs/.coverage-gaps.json +13 -55
- package/docs/.last-generated +1 -1
- package/docs/features/provider-runtimes.md +4 -0
- package/docs/features/schedules.md +32 -4
- package/docs/features/settings.md +28 -5
- package/docs/features/tables.md +9 -2
- package/docs/features/workflows.md +10 -4
- package/docs/journeys/developer.md +15 -1
- package/docs/journeys/personal-use.md +21 -4
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
- package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
- package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
- package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
- package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
- package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
- package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
- package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
- package/package.json +3 -2
- package/src/__tests__/instrumentation-smoke.test.ts +15 -0
- package/src/app/analytics/page.tsx +1 -21
- package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
- package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
- package/src/app/api/instance/config/route.ts +41 -0
- package/src/app/api/instance/init/route.ts +34 -0
- package/src/app/api/instance/upgrade/check/route.ts +26 -0
- package/src/app/api/instance/upgrade/route.ts +96 -0
- package/src/app/api/instance/upgrade/status/route.ts +35 -0
- package/src/app/api/memory/route.ts +0 -11
- package/src/app/api/notifications/route.ts +4 -2
- package/src/app/api/projects/[id]/route.ts +5 -155
- package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
- package/src/app/api/schedules/[id]/execute/route.ts +111 -0
- package/src/app/api/schedules/[id]/route.ts +9 -1
- package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
- package/src/app/api/schedules/route.ts +3 -12
- package/src/app/api/settings/openai/login/route.ts +22 -0
- package/src/app/api/settings/openai/logout/route.ts +7 -0
- package/src/app/api/settings/openai/route.ts +21 -1
- package/src/app/api/settings/providers/route.ts +35 -8
- package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
- package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
- package/src/app/api/tables/[id]/enrich/route.ts +147 -0
- package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
- package/src/app/api/tasks/[id]/execute/route.ts +0 -21
- package/src/app/api/workflows/[id]/resume/route.ts +59 -0
- package/src/app/api/workflows/[id]/status/route.ts +22 -8
- package/src/app/api/workspace/context/route.ts +2 -0
- package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
- package/src/app/chat/page.tsx +11 -0
- package/src/app/inbox/page.tsx +12 -5
- package/src/app/layout.tsx +42 -21
- package/src/app/page.tsx +0 -2
- package/src/app/settings/page.tsx +6 -9
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +408 -0
- package/src/components/chat/chat-command-popover.tsx +2 -2
- package/src/components/chat/chat-input.tsx +2 -3
- package/src/components/chat/chat-session-provider.tsx +720 -0
- package/src/components/chat/chat-shell.tsx +92 -401
- package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
- package/src/components/instance/instance-section.tsx +382 -0
- package/src/components/instance/upgrade-badge.tsx +219 -0
- package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
- package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
- package/src/components/notifications/batch-proposal-review.tsx +20 -5
- package/src/components/notifications/inbox-list.tsx +11 -2
- package/src/components/notifications/notification-item.tsx +56 -2
- package/src/components/notifications/pending-approval-host.tsx +56 -37
- package/src/components/schedules/schedule-create-sheet.tsx +19 -1
- package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
- package/src/components/schedules/schedule-form.tsx +31 -0
- package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
- package/src/components/settings/auth-method-selector.tsx +19 -4
- package/src/components/settings/auth-status-badge.tsx +28 -3
- package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
- package/src/components/settings/openai-runtime-section.tsx +7 -1
- package/src/components/settings/providers-runtimes-section.tsx +138 -19
- package/src/components/shared/app-sidebar.tsx +4 -3
- package/src/components/shared/command-palette.tsx +4 -5
- package/src/components/shared/theme-toggle.tsx +5 -24
- package/src/components/shared/workspace-indicator.tsx +61 -2
- package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
- package/src/components/tables/table-create-sheet.tsx +4 -0
- package/src/components/tables/table-enrichment-runs.tsx +103 -0
- package/src/components/tables/table-enrichment-sheet.tsx +538 -0
- package/src/components/tables/table-spreadsheet.tsx +29 -5
- package/src/components/tables/table-toolbar.tsx +10 -1
- package/src/components/tasks/kanban-board.tsx +1 -0
- package/src/components/tasks/kanban-column.tsx +53 -14
- package/src/components/tasks/task-bento-grid.tsx +19 -0
- package/src/components/tasks/task-card.tsx +26 -3
- package/src/components/tasks/task-chip-bar.tsx +24 -0
- package/src/components/tasks/task-result-renderer.tsx +1 -1
- package/src/components/workflows/delay-step-body.tsx +109 -0
- package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
- package/src/components/workflows/loop-status-view.tsx +1 -1
- package/src/components/workflows/shared/step-result.tsx +78 -0
- package/src/components/workflows/shared/workflow-header.tsx +141 -0
- package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
- package/src/components/workflows/swarm-dashboard.tsx +2 -15
- package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
- package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
- package/src/components/workflows/workflow-form-view.tsx +133 -16
- package/src/components/workflows/workflow-status-view.tsx +30 -740
- package/src/instrumentation-node.ts +94 -0
- package/src/instrumentation.ts +4 -48
- package/src/lib/agents/__tests__/claude-agent.test.ts +199 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
- package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
- package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
- package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
- package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
- package/src/lib/agents/claude-agent.ts +155 -18
- package/src/lib/agents/execution-manager.ts +0 -35
- package/src/lib/agents/learned-context.ts +0 -12
- package/src/lib/agents/learning-session.ts +18 -5
- package/src/lib/agents/profiles/__tests__/registry.test.ts +6 -4
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +70 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +32 -0
- package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
- package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
- package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
- package/src/lib/agents/runtime/openai-codex.ts +29 -60
- package/src/lib/agents/runtime/types.ts +8 -0
- package/src/lib/book/chapter-mapping.ts +11 -0
- package/src/lib/book/content.ts +10 -0
- package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
- package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
- package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
- package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
- package/src/lib/chat/active-streams.ts +27 -0
- package/src/lib/chat/codex-engine.ts +16 -17
- package/src/lib/chat/context-builder.ts +5 -3
- package/src/lib/chat/engine.ts +50 -3
- package/src/lib/chat/reconcile.ts +117 -0
- package/src/lib/chat/stagent-tools.ts +1 -0
- package/src/lib/chat/stream-telemetry.ts +132 -0
- package/src/lib/chat/suggested-prompts.ts +28 -1
- package/src/lib/chat/system-prompt.ts +26 -1
- package/src/lib/chat/tool-catalog.ts +2 -1
- package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
- package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +352 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +217 -0
- package/src/lib/chat/tools/document-tools.ts +29 -13
- package/src/lib/chat/tools/helpers.ts +39 -0
- package/src/lib/chat/tools/notification-tools.ts +9 -5
- package/src/lib/chat/tools/project-tools.ts +33 -0
- package/src/lib/chat/tools/schedule-tools.ts +44 -11
- package/src/lib/chat/tools/table-tools.ts +71 -0
- package/src/lib/chat/tools/task-tools.ts +84 -20
- package/src/lib/chat/tools/workflow-tools.ts +234 -32
- package/src/lib/constants/settings.ts +8 -18
- package/src/lib/data/__tests__/clear.test.ts +56 -2
- package/src/lib/data/clear.ts +20 -15
- package/src/lib/data/delete-project.ts +171 -0
- package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
- package/src/lib/db/bootstrap.ts +45 -16
- package/src/lib/db/index.ts +5 -0
- package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
- package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
- package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
- package/src/lib/db/migrations/0026_drop_license.sql +3 -0
- package/src/lib/db/migrations/meta/_journal.json +21 -0
- package/src/lib/db/schema.ts +68 -23
- package/src/lib/environment/workspace-context.ts +13 -1
- package/src/lib/import/dedup.ts +4 -54
- package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
- package/src/lib/instance/__tests__/detect.test.ts +115 -0
- package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
- package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
- package/src/lib/instance/__tests__/settings.test.ts +83 -0
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +131 -0
- package/src/lib/instance/bootstrap.ts +270 -0
- package/src/lib/instance/detect.ts +49 -0
- package/src/lib/instance/fingerprint.ts +78 -0
- package/src/lib/instance/git-ops.ts +95 -0
- package/src/lib/instance/settings.ts +61 -0
- package/src/lib/instance/types.ts +77 -0
- package/src/lib/instance/upgrade-poller.ts +153 -0
- package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
- package/src/lib/notifications/visibility.ts +33 -0
- package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
- package/src/lib/schedules/__tests__/config.test.ts +62 -0
- package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
- package/src/lib/schedules/__tests__/integration.test.ts +82 -0
- package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
- package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
- package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
- package/src/lib/schedules/collision-check.ts +105 -0
- package/src/lib/schedules/config.ts +53 -0
- package/src/lib/schedules/scheduler.ts +232 -13
- package/src/lib/schedules/slot-claim.ts +105 -0
- package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
- package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
- package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
- package/src/lib/settings/openai-auth.ts +105 -10
- package/src/lib/settings/openai-login-manager.ts +260 -0
- package/src/lib/settings/runtime-setup.ts +14 -4
- package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
- package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
- package/src/lib/tables/enrichment-planner.ts +454 -0
- package/src/lib/tables/enrichment.ts +328 -0
- package/src/lib/tables/query-builder.ts +5 -2
- package/src/lib/tables/trigger-evaluator.ts +3 -2
- package/src/lib/theme.ts +71 -0
- package/src/lib/usage/ledger.ts +2 -18
- package/src/lib/util/__tests__/similarity.test.ts +106 -0
- package/src/lib/util/similarity.ts +77 -0
- package/src/lib/utils/format-timestamp.ts +24 -0
- package/src/lib/utils/stagent-paths.ts +12 -0
- package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
- package/src/lib/validators/__tests__/settings.test.ts +10 -0
- package/src/lib/validators/blueprint.ts +70 -9
- package/src/lib/validators/profile.ts +2 -2
- package/src/lib/validators/settings.ts +3 -1
- package/src/lib/workflows/__tests__/delay.test.ts +196 -0
- package/src/lib/workflows/__tests__/engine.test.ts +8 -0
- package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
- package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
- package/src/lib/workflows/blueprints/instantiator.ts +22 -1
- package/src/lib/workflows/blueprints/types.ts +10 -2
- package/src/lib/workflows/delay.ts +106 -0
- package/src/lib/workflows/engine.ts +207 -4
- package/src/lib/workflows/loop-executor.ts +349 -24
- package/src/lib/workflows/post-action.ts +91 -0
- package/src/lib/workflows/types.ts +166 -1
- package/src/app/api/license/checkout/route.ts +0 -28
- package/src/app/api/license/portal/route.ts +0 -26
- package/src/app/api/license/route.ts +0 -89
- package/src/app/api/license/usage/route.ts +0 -63
- package/src/app/api/marketplace/browse/route.ts +0 -15
- package/src/app/api/marketplace/import/route.ts +0 -28
- package/src/app/api/marketplace/publish/route.ts +0 -40
- package/src/app/api/onboarding/email/route.ts +0 -53
- package/src/app/api/settings/telemetry/route.ts +0 -14
- package/src/app/api/sync/export/route.ts +0 -54
- package/src/app/api/sync/restore/route.ts +0 -37
- package/src/app/api/sync/sessions/route.ts +0 -24
- package/src/app/auth/callback/route.ts +0 -73
- package/src/app/marketplace/page.tsx +0 -19
- package/src/components/analytics/analytics-gate-card.tsx +0 -101
- package/src/components/marketplace/blueprint-card.tsx +0 -61
- package/src/components/marketplace/marketplace-browser.tsx +0 -131
- package/src/components/onboarding/email-capture-card.tsx +0 -104
- package/src/components/settings/activation-form.tsx +0 -95
- package/src/components/settings/cloud-account-section.tsx +0 -147
- package/src/components/settings/cloud-sync-section.tsx +0 -155
- package/src/components/settings/subscription-section.tsx +0 -410
- package/src/components/settings/telemetry-section.tsx +0 -80
- package/src/components/shared/premium-gate-overlay.tsx +0 -50
- package/src/components/shared/schedule-gate-dialog.tsx +0 -64
- package/src/components/shared/upgrade-banner.tsx +0 -112
- package/src/hooks/use-supabase-auth.ts +0 -79
- package/src/lib/billing/email.ts +0 -54
- package/src/lib/billing/products.ts +0 -80
- package/src/lib/billing/stripe.ts +0 -101
- package/src/lib/cloud/supabase-browser.ts +0 -32
- package/src/lib/cloud/supabase-client.ts +0 -56
- package/src/lib/license/__tests__/features.test.ts +0 -56
- package/src/lib/license/__tests__/key-format.test.ts +0 -88
- package/src/lib/license/__tests__/manager.test.ts +0 -64
- package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
- package/src/lib/license/cloud-validation.ts +0 -60
- package/src/lib/license/features.ts +0 -44
- package/src/lib/license/key-format.ts +0 -101
- package/src/lib/license/limit-check.ts +0 -111
- package/src/lib/license/limit-queries.ts +0 -51
- package/src/lib/license/manager.ts +0 -345
- package/src/lib/license/notifications.ts +0 -59
- package/src/lib/license/tier-limits.ts +0 -71
- package/src/lib/marketplace/marketplace-client.ts +0 -107
- package/src/lib/sync/cloud-sync.ts +0 -235
- package/src/lib/telemetry/conversion-events.ts +0 -71
- package/src/lib/telemetry/queue.ts +0 -122
- package/src/lib/validators/license.ts +0 -33
package/src/lib/db/schema.ts
CHANGED
|
@@ -40,6 +40,21 @@ export const tasks = sqliteTable(
|
|
|
40
40
|
workflowRunNumber: integer("workflow_run_number"),
|
|
41
41
|
/** Resolved per-task budget cap in USD — set by workflow engine for child tasks */
|
|
42
42
|
maxBudgetUsd: real("max_budget_usd"),
|
|
43
|
+
/** When the slot for this task was atomically claimed */
|
|
44
|
+
slotClaimedAt: integer("slot_claimed_at", { mode: "timestamp" }),
|
|
45
|
+
/** Wall-clock expiry; reaper aborts tasks whose lease has passed */
|
|
46
|
+
leaseExpiresAt: integer("lease_expires_at", { mode: "timestamp" }),
|
|
47
|
+
/**
|
|
48
|
+
* Explicit terminal-state reason written by the runtime adapter at
|
|
49
|
+
* failure/abort transitions (e.g. 'turn_limit_exceeded', 'lease_expired',
|
|
50
|
+
* 'aborted', 'sdk_error'). Distinct from `result` — `result` holds the
|
|
51
|
+
* agent's final output text, while `failureReason` holds a machine-readable
|
|
52
|
+
* classifier that drives scheduler failure-streak logic without re-parsing
|
|
53
|
+
* error prose.
|
|
54
|
+
*/
|
|
55
|
+
failureReason: text("failure_reason"),
|
|
56
|
+
/** Per-task turn budget copied from schedules.maxTurns at firing time */
|
|
57
|
+
maxTurns: integer("max_turns"),
|
|
43
58
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
44
59
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
45
60
|
},
|
|
@@ -49,6 +64,7 @@ export const tasks = sqliteTable(
|
|
|
49
64
|
index("idx_tasks_workflow_id").on(table.workflowId),
|
|
50
65
|
index("idx_tasks_schedule_id").on(table.scheduleId),
|
|
51
66
|
index("idx_tasks_agent_profile").on(table.agentProfile),
|
|
67
|
+
index("idx_tasks_running_scheduled").on(table.status, table.sourceType, table.leaseExpiresAt),
|
|
52
68
|
]
|
|
53
69
|
);
|
|
54
70
|
|
|
@@ -65,6 +81,13 @@ export const workflows = sqliteTable("workflows", {
|
|
|
65
81
|
runNumber: integer("run_number").default(0).notNull(),
|
|
66
82
|
/** Runtime to use for all steps (nullable — falls back to system default) */
|
|
67
83
|
runtimeId: text("runtime_id"),
|
|
84
|
+
/**
|
|
85
|
+
* Epoch millisecond timestamp at which a paused (delayed) workflow is due to resume.
|
|
86
|
+
* Null for workflows that are not waiting on a delay step. Indexed via
|
|
87
|
+
* idx_workflows_resume_at (partial index on non-null values) so the scheduler tick
|
|
88
|
+
* can efficiently find due workflows. See features/workflow-step-delays.md.
|
|
89
|
+
*/
|
|
90
|
+
resumeAt: integer("resume_at"),
|
|
68
91
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
69
92
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
70
93
|
});
|
|
@@ -210,6 +233,20 @@ export const schedules = sqliteTable(
|
|
|
210
233
|
failureStreak: integer("failure_streak").default(0).notNull(),
|
|
211
234
|
/** Detected reason for the most recent failure (turn_limit_exceeded, timeout, etc.) */
|
|
212
235
|
lastFailureReason: text("last_failure_reason"),
|
|
236
|
+
/** Hard cap on turns per firing; NULL inherits the global MAX_TURNS setting */
|
|
237
|
+
maxTurns: integer("max_turns"),
|
|
238
|
+
/** Timestamp when maxTurns was last edited — drives first-breach grace */
|
|
239
|
+
maxTurnsSetAt: integer("max_turns_set_at", { mode: "timestamp" }),
|
|
240
|
+
/** Wall-clock lease override in seconds; NULL inherits global default (1200s) */
|
|
241
|
+
maxRunDurationSec: integer("max_run_duration_sec"),
|
|
242
|
+
/**
|
|
243
|
+
* Counter separate from failureStreak — increments only on maxTurns breach.
|
|
244
|
+
* Reset to 0 on any non-breach outcome (successful run, generic failure, or
|
|
245
|
+
* first-breach grace window after maxTurnsSetAt). Auto-pause at 5. This
|
|
246
|
+
* higher threshold + grace window protects users from tripping auto-pause
|
|
247
|
+
* via a misconfigured maxTurns edit.
|
|
248
|
+
*/
|
|
249
|
+
turnBudgetBreachStreak: integer("turn_budget_breach_streak").default(0).notNull(),
|
|
213
250
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
214
251
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
215
252
|
},
|
|
@@ -307,6 +344,7 @@ export const usageLedger = sqliteTable(
|
|
|
307
344
|
"context_summarization",
|
|
308
345
|
"chat_turn",
|
|
309
346
|
"profile_assist",
|
|
347
|
+
"manual_force_bypass",
|
|
310
348
|
],
|
|
311
349
|
}).notNull(),
|
|
312
350
|
runtimeId: text("runtime_id").notNull(),
|
|
@@ -1162,29 +1200,6 @@ export const snapshots = sqliteTable(
|
|
|
1162
1200
|
|
|
1163
1201
|
export type SnapshotRow = InferSelectModel<typeof snapshots>;
|
|
1164
1202
|
|
|
1165
|
-
// ── License ──────────────────────────────────────────────────────────
|
|
1166
|
-
|
|
1167
|
-
export const license = sqliteTable("license", {
|
|
1168
|
-
id: text("id").primaryKey(),
|
|
1169
|
-
supabaseUserId: text("supabase_user_id"),
|
|
1170
|
-
tier: text("tier", { enum: ["community", "solo", "operator", "scale"] })
|
|
1171
|
-
.default("community")
|
|
1172
|
-
.notNull(),
|
|
1173
|
-
status: text("status", { enum: ["active", "inactive", "grace"] })
|
|
1174
|
-
.default("inactive")
|
|
1175
|
-
.notNull(),
|
|
1176
|
-
email: text("email"),
|
|
1177
|
-
activatedAt: integer("activated_at", { mode: "timestamp" }),
|
|
1178
|
-
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
|
1179
|
-
lastValidatedAt: integer("last_validated_at", { mode: "timestamp" }),
|
|
1180
|
-
gracePeriodExpiresAt: integer("grace_period_expires_at", { mode: "timestamp" }),
|
|
1181
|
-
encryptedToken: text("encrypted_token"),
|
|
1182
|
-
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
1183
|
-
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
1184
|
-
});
|
|
1185
|
-
|
|
1186
|
-
export type LicenseRow = InferSelectModel<typeof license>;
|
|
1187
|
-
|
|
1188
1203
|
// ── Workflow Execution Stats ─────────────────────────────────────────
|
|
1189
1204
|
|
|
1190
1205
|
export const workflowExecutionStats = sqliteTable("workflow_execution_stats", {
|
|
@@ -1212,3 +1227,33 @@ export const workflowExecutionStats = sqliteTable("workflow_execution_stats", {
|
|
|
1212
1227
|
});
|
|
1213
1228
|
|
|
1214
1229
|
export type WorkflowExecutionStatsRow = InferSelectModel<typeof workflowExecutionStats>;
|
|
1230
|
+
|
|
1231
|
+
// ── Schedule Firing Metrics ───────────────────────────────────────────
|
|
1232
|
+
|
|
1233
|
+
export const scheduleFiringMetrics = sqliteTable(
|
|
1234
|
+
"schedule_firing_metrics",
|
|
1235
|
+
{
|
|
1236
|
+
id: text("id").primaryKey(),
|
|
1237
|
+
scheduleId: text("schedule_id")
|
|
1238
|
+
.references(() => schedules.id)
|
|
1239
|
+
.notNull(),
|
|
1240
|
+
taskId: text("task_id").references(() => tasks.id),
|
|
1241
|
+
firedAt: integer("fired_at", { mode: "timestamp" }).notNull(),
|
|
1242
|
+
slotClaimedAt: integer("slot_claimed_at", { mode: "timestamp" }),
|
|
1243
|
+
completedAt: integer("completed_at", { mode: "timestamp" }),
|
|
1244
|
+
slotWaitMs: integer("slot_wait_ms"),
|
|
1245
|
+
durationMs: integer("duration_ms"),
|
|
1246
|
+
turnCount: integer("turn_count"),
|
|
1247
|
+
maxTurnsAtFiring: integer("max_turns_at_firing"),
|
|
1248
|
+
eventLoopLagMs: real("event_loop_lag_ms"),
|
|
1249
|
+
peakRssMb: integer("peak_rss_mb"),
|
|
1250
|
+
chatStreamsActive: integer("chat_streams_active"),
|
|
1251
|
+
concurrentSchedules: integer("concurrent_schedules"),
|
|
1252
|
+
failureReason: text("failure_reason"),
|
|
1253
|
+
},
|
|
1254
|
+
(table) => [
|
|
1255
|
+
index("idx_sfm_schedule_time").on(table.scheduleId, table.firedAt),
|
|
1256
|
+
]
|
|
1257
|
+
);
|
|
1258
|
+
|
|
1259
|
+
export type ScheduleFiringMetricRow = InferSelectModel<typeof scheduleFiringMetrics>;
|
|
@@ -3,6 +3,8 @@ import { homedir } from "os";
|
|
|
3
3
|
import { execFileSync } from "child_process";
|
|
4
4
|
import { statSync } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
|
+
import { getStagentDataDir } from "@/lib/utils/stagent-paths";
|
|
7
|
+
import { isDevMode, isPrivateInstance } from "@/lib/instance/detect";
|
|
6
8
|
|
|
7
9
|
/** The directory the user launched stagent from (falls back to process.cwd()). */
|
|
8
10
|
export function getLaunchCwd(): string {
|
|
@@ -15,6 +17,8 @@ export interface WorkspaceContext {
|
|
|
15
17
|
parentPath: string;
|
|
16
18
|
gitBranch: string | null;
|
|
17
19
|
isWorktree: boolean;
|
|
20
|
+
dataDir: string;
|
|
21
|
+
dataDirMismatch: boolean;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export function getWorkspaceContext(): WorkspaceContext {
|
|
@@ -47,5 +51,13 @@ export function getWorkspaceContext(): WorkspaceContext {
|
|
|
47
51
|
// no .git at all
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
const rawDataDir = getStagentDataDir();
|
|
55
|
+
const dataDir = rawDataDir.startsWith(home)
|
|
56
|
+
? "~" + rawDataDir.slice(home.length)
|
|
57
|
+
: rawDataDir;
|
|
58
|
+
|
|
59
|
+
// Red flag: non-main repo using the default shared DB instead of its own
|
|
60
|
+
const dataDirMismatch = !isDevMode(cwd) && !isPrivateInstance();
|
|
61
|
+
|
|
62
|
+
return { cwd, folderName, parentPath, gitBranch, isWorktree, dataDir, dataDirMismatch };
|
|
51
63
|
}
|
package/src/lib/import/dedup.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deduplication engine for profile import.
|
|
3
3
|
* Three-tier matching: exact ID, name match, content similarity.
|
|
4
|
+
*
|
|
5
|
+
* Keyword / Jaccard / tag-overlap helpers are shared with the chat workflow
|
|
6
|
+
* dedup path — see `src/lib/util/similarity.ts`.
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
9
|
import type { ProfileConfig } from "@/lib/validators/profile";
|
|
7
10
|
import type { AgentProfile } from "@/lib/agents/profiles/types";
|
|
11
|
+
import { extractKeywords, jaccard, tagOverlap } from "@/lib/util/similarity";
|
|
8
12
|
|
|
9
13
|
export interface DedupResult {
|
|
10
14
|
candidate: ProfileConfig;
|
|
@@ -15,60 +19,6 @@ export interface DedupResult {
|
|
|
15
19
|
similarity?: number;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
|
-
/** Common stop words to exclude from keyword extraction. */
|
|
19
|
-
const STOP_WORDS = new Set([
|
|
20
|
-
"the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
|
|
21
|
-
"her", "was", "one", "our", "out", "has", "have", "that", "this", "with",
|
|
22
|
-
"from", "they", "been", "will", "each", "make", "like", "into", "them",
|
|
23
|
-
"some", "when", "what", "your", "should", "would", "could", "about",
|
|
24
|
-
"which", "their", "other", "than", "then", "more", "also", "been",
|
|
25
|
-
"only", "must", "does", "here", "just", "over", "such", "after",
|
|
26
|
-
"before", "between", "through", "where", "these", "those", "being",
|
|
27
|
-
"using", "ensure", "every", "following", "include",
|
|
28
|
-
]);
|
|
29
|
-
|
|
30
|
-
/** Extract meaningful keywords from text. */
|
|
31
|
-
function extractKeywords(text: string, limit = 20): Set<string> {
|
|
32
|
-
const words = text
|
|
33
|
-
.toLowerCase()
|
|
34
|
-
.replace(/[^a-z0-9\s-]/g, " ")
|
|
35
|
-
.split(/\s+/)
|
|
36
|
-
.filter((w) => w.length > 3 && w.length < 30 && !STOP_WORDS.has(w));
|
|
37
|
-
|
|
38
|
-
// Count frequency
|
|
39
|
-
const freq = new Map<string, number>();
|
|
40
|
-
for (const word of words) {
|
|
41
|
-
freq.set(word, (freq.get(word) ?? 0) + 1);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Sort by frequency, take top N
|
|
45
|
-
const sorted = Array.from(freq.entries())
|
|
46
|
-
.sort((a, b) => b[1] - a[1])
|
|
47
|
-
.slice(0, limit)
|
|
48
|
-
.map(([word]) => word);
|
|
49
|
-
|
|
50
|
-
return new Set(sorted);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Jaccard similarity between two sets. */
|
|
54
|
-
function jaccard(a: Set<string>, b: Set<string>): number {
|
|
55
|
-
if (a.size === 0 && b.size === 0) return 0;
|
|
56
|
-
let intersection = 0;
|
|
57
|
-
for (const item of a) {
|
|
58
|
-
if (b.has(item)) intersection++;
|
|
59
|
-
}
|
|
60
|
-
const union = a.size + b.size - intersection;
|
|
61
|
-
return union === 0 ? 0 : intersection / union;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Tag overlap ratio (how many of candidate's tags match existing). */
|
|
65
|
-
function tagOverlap(candidateTags: string[], existingTags: string[]): number {
|
|
66
|
-
if (candidateTags.length === 0) return 0;
|
|
67
|
-
const existingSet = new Set(existingTags.map((t) => t.toLowerCase()));
|
|
68
|
-
const matches = candidateTags.filter((t) => existingSet.has(t.toLowerCase()));
|
|
69
|
-
return matches.length / candidateTags.length;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
22
|
/**
|
|
73
23
|
* Check a batch of candidate profiles against all existing profiles for duplicates.
|
|
74
24
|
*/
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { execFileSync } from "child_process";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
|
|
7
|
+
let tempDir: string;
|
|
8
|
+
let dataDir: string;
|
|
9
|
+
|
|
10
|
+
function runGit(args: string[], cwd: string) {
|
|
11
|
+
execFileSync("git", args, { cwd, stdio: "pipe" });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function initRepo(dir: string) {
|
|
15
|
+
runGit(["init", "-b", "main"], dir);
|
|
16
|
+
runGit(["config", "user.email", "test@example.com"], dir);
|
|
17
|
+
runGit(["config", "user.name", "Test"], dir);
|
|
18
|
+
writeFileSync(join(dir, "README.md"), "# test\n");
|
|
19
|
+
runGit(["add", "README.md"], dir);
|
|
20
|
+
runGit(["commit", "-m", "initial"], dir);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
tempDir = mkdtempSync(join(tmpdir(), "stagent-bootstrap-repo-"));
|
|
25
|
+
dataDir = mkdtempSync(join(tmpdir(), "stagent-bootstrap-data-"));
|
|
26
|
+
initRepo(tempDir);
|
|
27
|
+
vi.resetModules();
|
|
28
|
+
vi.unstubAllEnvs();
|
|
29
|
+
vi.stubEnv("STAGENT_DATA_DIR", dataDir);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.unstubAllEnvs();
|
|
34
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
35
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("ensureInstanceConfig (Phase A)", () => {
|
|
39
|
+
it("generates a new instanceId on first call", async () => {
|
|
40
|
+
const { ensureInstanceConfig } = await import("../bootstrap");
|
|
41
|
+
const result = await ensureInstanceConfig();
|
|
42
|
+
expect(result.status).toBe("ok");
|
|
43
|
+
const { getInstanceConfig } = await import("../settings");
|
|
44
|
+
const config = getInstanceConfig();
|
|
45
|
+
expect(config).not.toBeNull();
|
|
46
|
+
expect(config!.instanceId).toMatch(/^[a-f0-9-]{36}$/);
|
|
47
|
+
expect(config!.branchName).toBe("local");
|
|
48
|
+
// STAGENT_DATA_DIR is stubbed to a temp dir (non-default), so this clone
|
|
49
|
+
// correctly registers as a private instance in the test environment.
|
|
50
|
+
expect(config!.isPrivateInstance).toBe(true);
|
|
51
|
+
expect(config!.createdAt).toBeGreaterThan(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("does not regenerate instanceId on subsequent calls", async () => {
|
|
55
|
+
const { ensureInstanceConfig } = await import("../bootstrap");
|
|
56
|
+
await ensureInstanceConfig();
|
|
57
|
+
const { getInstanceConfig } = await import("../settings");
|
|
58
|
+
const firstId = getInstanceConfig()!.instanceId;
|
|
59
|
+
await ensureInstanceConfig();
|
|
60
|
+
const secondId = getInstanceConfig()!.instanceId;
|
|
61
|
+
expect(secondId).toBe(firstId);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("ensureLocalBranch (Phase A)", () => {
|
|
66
|
+
it("creates local branch at current HEAD when it does not exist", async () => {
|
|
67
|
+
const { createGitOps } = await import("../git-ops");
|
|
68
|
+
const { ensureLocalBranch } = await import("../bootstrap");
|
|
69
|
+
const ops = createGitOps(tempDir);
|
|
70
|
+
const mainSha = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
|
|
71
|
+
const result = ensureLocalBranch(ops);
|
|
72
|
+
expect(result.status).toBe("ok");
|
|
73
|
+
expect(ops.branchExists("local")).toBe(true);
|
|
74
|
+
expect(ops.getCurrentBranch()).toBe("local");
|
|
75
|
+
const localSha = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
|
|
76
|
+
expect(localSha).toBe(mainSha);
|
|
77
|
+
const mainShaAfter = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
|
|
78
|
+
expect(mainShaAfter).toBe(mainSha);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("is a no-op when local branch already exists", async () => {
|
|
82
|
+
const { createGitOps } = await import("../git-ops");
|
|
83
|
+
const { ensureLocalBranch } = await import("../bootstrap");
|
|
84
|
+
const ops = createGitOps(tempDir);
|
|
85
|
+
ops.createAndCheckoutBranch("local");
|
|
86
|
+
const shaBefore = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
|
|
87
|
+
const result = ensureLocalBranch(ops);
|
|
88
|
+
expect(result.status).toBe("skipped");
|
|
89
|
+
expect(result.reason).toBe("branch_exists");
|
|
90
|
+
const shaAfter = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
|
|
91
|
+
expect(shaAfter).toBe(shaBefore);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("creates local at current HEAD even when user has local commits on main", async () => {
|
|
95
|
+
writeFileSync(join(tempDir, "custom.txt"), "user work\n");
|
|
96
|
+
runGit(["add", "custom.txt"], tempDir);
|
|
97
|
+
runGit(["commit", "-m", "user customization"], tempDir);
|
|
98
|
+
const mainSha = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
|
|
99
|
+
|
|
100
|
+
const { createGitOps } = await import("../git-ops");
|
|
101
|
+
const { ensureLocalBranch } = await import("../bootstrap");
|
|
102
|
+
const ops = createGitOps(tempDir);
|
|
103
|
+
const result = ensureLocalBranch(ops);
|
|
104
|
+
|
|
105
|
+
expect(result.status).toBe("ok");
|
|
106
|
+
expect(ops.branchExists("local")).toBe(true);
|
|
107
|
+
const localSha = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
|
|
108
|
+
expect(localSha).toBe(mainSha);
|
|
109
|
+
const mainShaAfter = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
|
|
110
|
+
expect(mainShaAfter).toBe(mainSha);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("ensurePrePushHook (Phase B)", () => {
|
|
115
|
+
it("writes a pre-push hook with the STAGENT_HOOK_VERSION marker", async () => {
|
|
116
|
+
const { createGitOps } = await import("../git-ops");
|
|
117
|
+
const { ensurePrePushHook } = await import("../bootstrap");
|
|
118
|
+
const ops = createGitOps(tempDir);
|
|
119
|
+
const result = ensurePrePushHook(ops);
|
|
120
|
+
expect(result.status).toBe("ok");
|
|
121
|
+
const hookPath = join(tempDir, ".git", "hooks", "pre-push");
|
|
122
|
+
expect(existsSync(hookPath)).toBe(true);
|
|
123
|
+
const content = readFileSync(hookPath, "utf-8");
|
|
124
|
+
expect(content).toContain("STAGENT_HOOK_VERSION=");
|
|
125
|
+
expect(content).toContain("ALLOW_PRIVATE_PUSH");
|
|
126
|
+
const mode = statSync(hookPath).mode & 0o777;
|
|
127
|
+
expect(mode & 0o100).toBeTruthy();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("is a no-op when a hook with matching version already exists", async () => {
|
|
131
|
+
const { createGitOps } = await import("../git-ops");
|
|
132
|
+
const { ensurePrePushHook } = await import("../bootstrap");
|
|
133
|
+
const ops = createGitOps(tempDir);
|
|
134
|
+
ensurePrePushHook(ops); // first install
|
|
135
|
+
const firstMtime = statSync(join(tempDir, ".git", "hooks", "pre-push")).mtimeMs;
|
|
136
|
+
const result = ensurePrePushHook(ops);
|
|
137
|
+
expect(result.status).toBe("skipped");
|
|
138
|
+
expect(result.reason).toBe("already_installed");
|
|
139
|
+
const secondMtime = statSync(join(tempDir, ".git", "hooks", "pre-push")).mtimeMs;
|
|
140
|
+
expect(secondMtime).toBe(firstMtime);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("backs up a pre-existing non-stagent hook before installing", async () => {
|
|
144
|
+
const customHook = "#!/bin/sh\necho custom hook\n";
|
|
145
|
+
writeFileSync(join(tempDir, ".git", "hooks", "pre-push"), customHook);
|
|
146
|
+
chmodSync(join(tempDir, ".git", "hooks", "pre-push"), 0o755);
|
|
147
|
+
const { createGitOps } = await import("../git-ops");
|
|
148
|
+
const { ensurePrePushHook } = await import("../bootstrap");
|
|
149
|
+
const ops = createGitOps(tempDir);
|
|
150
|
+
const result = ensurePrePushHook(ops);
|
|
151
|
+
expect(result.status).toBe("ok");
|
|
152
|
+
const backupPath = join(tempDir, ".git", "hooks", "pre-push.stagent-backup");
|
|
153
|
+
expect(existsSync(backupPath)).toBe(true);
|
|
154
|
+
expect(readFileSync(backupPath, "utf-8")).toBe(customHook);
|
|
155
|
+
expect(readFileSync(join(tempDir, ".git", "hooks", "pre-push"), "utf-8"))
|
|
156
|
+
.toContain("STAGENT_HOOK_VERSION=");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("ensureBranchPushConfig (Phase B)", () => {
|
|
161
|
+
it("sets branch.local.pushRemote=no_push", async () => {
|
|
162
|
+
const { createGitOps } = await import("../git-ops");
|
|
163
|
+
const { ensureLocalBranch, ensureBranchPushConfig } = await import("../bootstrap");
|
|
164
|
+
const ops = createGitOps(tempDir);
|
|
165
|
+
ensureLocalBranch(ops);
|
|
166
|
+
const result = ensureBranchPushConfig(ops, ["local"]);
|
|
167
|
+
expect(result.status).toBe("ok");
|
|
168
|
+
const value = execFileSync("git", ["config", "--get", "branch.local.pushRemote"], { cwd: tempDir, encoding: "utf-8" }).trim();
|
|
169
|
+
expect(value).toBe("no_push");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("handles multiple blocked branches", async () => {
|
|
173
|
+
const { createGitOps } = await import("../git-ops");
|
|
174
|
+
const { ensureBranchPushConfig } = await import("../bootstrap");
|
|
175
|
+
const ops = createGitOps(tempDir);
|
|
176
|
+
ops.createAndCheckoutBranch("wealth-mgr");
|
|
177
|
+
ops.createAndCheckoutBranch("investor-mgr");
|
|
178
|
+
const result = ensureBranchPushConfig(ops, ["wealth-mgr", "investor-mgr"]);
|
|
179
|
+
expect(result.status).toBe("ok");
|
|
180
|
+
expect(execFileSync("git", ["config", "--get", "branch.wealth-mgr.pushRemote"], { cwd: tempDir, encoding: "utf-8" }).trim()).toBe("no_push");
|
|
181
|
+
expect(execFileSync("git", ["config", "--get", "branch.investor-mgr.pushRemote"], { cwd: tempDir, encoding: "utf-8" }).trim()).toBe("no_push");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("resolveConsentDecision", () => {
|
|
186
|
+
it("returns {shouldRunPhaseB: false, reason: 'not_yet'} when consent is not_yet (default)", async () => {
|
|
187
|
+
const { resolveConsentDecision } = await import("../bootstrap");
|
|
188
|
+
const decision = await resolveConsentDecision();
|
|
189
|
+
expect(decision.shouldRunPhaseB).toBe(false);
|
|
190
|
+
expect(decision.reason).toBe("not_yet");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("returns {shouldRunPhaseB: true} when consent is enabled", async () => {
|
|
194
|
+
const { setGuardrails } = await import("../settings");
|
|
195
|
+
await setGuardrails({
|
|
196
|
+
prePushHookInstalled: false,
|
|
197
|
+
prePushHookVersion: "",
|
|
198
|
+
pushRemoteBlocked: [],
|
|
199
|
+
consentStatus: "enabled",
|
|
200
|
+
firstBootCompletedAt: null,
|
|
201
|
+
});
|
|
202
|
+
const { resolveConsentDecision } = await import("../bootstrap");
|
|
203
|
+
const decision = await resolveConsentDecision();
|
|
204
|
+
expect(decision.shouldRunPhaseB).toBe(true);
|
|
205
|
+
expect(decision.reason).toBe("enabled");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("returns {shouldRunPhaseB: false, reason: 'declined_permanently'}", async () => {
|
|
209
|
+
const { setGuardrails } = await import("../settings");
|
|
210
|
+
await setGuardrails({
|
|
211
|
+
prePushHookInstalled: false,
|
|
212
|
+
prePushHookVersion: "",
|
|
213
|
+
pushRemoteBlocked: [],
|
|
214
|
+
consentStatus: "declined_permanently",
|
|
215
|
+
firstBootCompletedAt: null,
|
|
216
|
+
});
|
|
217
|
+
const { resolveConsentDecision } = await import("../bootstrap");
|
|
218
|
+
const decision = await resolveConsentDecision();
|
|
219
|
+
expect(decision.shouldRunPhaseB).toBe(false);
|
|
220
|
+
expect(decision.reason).toBe("declined_permanently");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("stamps firstBootCompletedAt on first call when it was null", async () => {
|
|
224
|
+
const { getGuardrails } = await import("../settings");
|
|
225
|
+
expect(getGuardrails().consentStatus).toBe("not_yet");
|
|
226
|
+
expect(getGuardrails().firstBootCompletedAt).toBeNull();
|
|
227
|
+
const { resolveConsentDecision } = await import("../bootstrap");
|
|
228
|
+
await resolveConsentDecision();
|
|
229
|
+
const after = getGuardrails();
|
|
230
|
+
expect(after.consentStatus).toBe("not_yet");
|
|
231
|
+
expect(after.firstBootCompletedAt).not.toBeNull();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("ensureInstance orchestrator", () => {
|
|
236
|
+
it("returns skipped with dev_mode_env when STAGENT_DEV_MODE=true", async () => {
|
|
237
|
+
vi.stubEnv("STAGENT_DEV_MODE", "true");
|
|
238
|
+
const { ensureInstance } = await import("../bootstrap");
|
|
239
|
+
const result = await ensureInstance(tempDir);
|
|
240
|
+
expect(result.skipped).toBe("dev_mode_env");
|
|
241
|
+
expect(result.steps).toEqual([]);
|
|
242
|
+
expect(existsSync(join(tempDir, ".git", "hooks", "pre-push"))).toBe(false);
|
|
243
|
+
const { createGitOps } = await import("../git-ops");
|
|
244
|
+
expect(createGitOps(tempDir).branchExists("local")).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("returns skipped with dev_mode_sentinel when sentinel file exists", async () => {
|
|
248
|
+
writeFileSync(join(tempDir, ".git", "stagent-dev-mode"), "");
|
|
249
|
+
const { ensureInstance } = await import("../bootstrap");
|
|
250
|
+
const result = await ensureInstance(tempDir);
|
|
251
|
+
expect(result.skipped).toBe("dev_mode_sentinel");
|
|
252
|
+
expect(result.steps).toEqual([]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("returns skipped with no_git when .git directory is absent", async () => {
|
|
256
|
+
const noGitDir = mkdtempSync(join(tmpdir(), "stagent-nogit-"));
|
|
257
|
+
try {
|
|
258
|
+
const { ensureInstance } = await import("../bootstrap");
|
|
259
|
+
const result = await ensureInstance(noGitDir);
|
|
260
|
+
expect(result.skipped).toBe("no_git");
|
|
261
|
+
} finally {
|
|
262
|
+
rmSync(noGitDir, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("runs Phase A and stamps consent state on fresh clone (consent not_yet)", async () => {
|
|
267
|
+
const { ensureInstance } = await import("../bootstrap");
|
|
268
|
+
const result = await ensureInstance(tempDir);
|
|
269
|
+
expect(result.skipped).toBeUndefined();
|
|
270
|
+
const steps = result.steps.map((s) => s.step);
|
|
271
|
+
expect(steps).toContain("instance-config");
|
|
272
|
+
expect(steps).toContain("local-branch");
|
|
273
|
+
expect(steps).not.toContain("pre-push-hook");
|
|
274
|
+
expect(steps).not.toContain("branch-push-config");
|
|
275
|
+
const { createGitOps } = await import("../git-ops");
|
|
276
|
+
expect(createGitOps(tempDir).branchExists("local")).toBe(true);
|
|
277
|
+
expect(existsSync(join(tempDir, ".git", "hooks", "pre-push"))).toBe(false);
|
|
278
|
+
const { getGuardrails } = await import("../settings");
|
|
279
|
+
expect(getGuardrails().firstBootCompletedAt).not.toBeNull();
|
|
280
|
+
expect(getGuardrails().consentStatus).toBe("not_yet");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("runs Phase B when consent is enabled", async () => {
|
|
284
|
+
const { setGuardrails } = await import("../settings");
|
|
285
|
+
await setGuardrails({
|
|
286
|
+
prePushHookInstalled: false,
|
|
287
|
+
prePushHookVersion: "",
|
|
288
|
+
pushRemoteBlocked: [],
|
|
289
|
+
consentStatus: "enabled",
|
|
290
|
+
firstBootCompletedAt: null,
|
|
291
|
+
});
|
|
292
|
+
const { ensureInstance } = await import("../bootstrap");
|
|
293
|
+
const result = await ensureInstance(tempDir);
|
|
294
|
+
const steps = result.steps.map((s) => s.step);
|
|
295
|
+
expect(steps).toContain("pre-push-hook");
|
|
296
|
+
expect(steps).toContain("branch-push-config");
|
|
297
|
+
expect(existsSync(join(tempDir, ".git", "hooks", "pre-push"))).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("STAGENT_INSTANCE_MODE=true override beats STAGENT_DEV_MODE=true", async () => {
|
|
301
|
+
vi.stubEnv("STAGENT_DEV_MODE", "true");
|
|
302
|
+
vi.stubEnv("STAGENT_INSTANCE_MODE", "true");
|
|
303
|
+
const { ensureInstance } = await import("../bootstrap");
|
|
304
|
+
const result = await ensureInstance(tempDir);
|
|
305
|
+
expect(result.skipped).toBeUndefined();
|
|
306
|
+
expect(result.steps.length).toBeGreaterThan(0);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("is a full no-op on the second call (idempotent)", async () => {
|
|
310
|
+
const { ensureInstance } = await import("../bootstrap");
|
|
311
|
+
await ensureInstance(tempDir);
|
|
312
|
+
const result = await ensureInstance(tempDir);
|
|
313
|
+
for (const step of result.steps) {
|
|
314
|
+
if (step.step === "instance-config" || step.step === "local-branch") {
|
|
315
|
+
expect(step.status).toBe("skipped");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("skips ensureLocalBranch with warning when rebase is in progress", async () => {
|
|
321
|
+
mkdirSync(join(tempDir, ".git", "rebase-merge"));
|
|
322
|
+
const { ensureInstance } = await import("../bootstrap");
|
|
323
|
+
const result = await ensureInstance(tempDir);
|
|
324
|
+
const branchStep = result.steps.find((s) => s.step === "local-branch");
|
|
325
|
+
expect(branchStep?.status).toBe("skipped");
|
|
326
|
+
expect(branchStep?.reason).toBe("rebase_in_progress");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("populates guardrails state after a Phase B run with consent=enabled", async () => {
|
|
330
|
+
// Regression test for the critical bug where ensureBranchPushConfig() set
|
|
331
|
+
// the git config values but never wrote the blocked branch list back to
|
|
332
|
+
// settings.instance.guardrails. The hook's grep would never match and all
|
|
333
|
+
// pushes would be silently allowed.
|
|
334
|
+
const { setGuardrails, getGuardrails } = await import("../settings");
|
|
335
|
+
await setGuardrails({
|
|
336
|
+
prePushHookInstalled: false,
|
|
337
|
+
prePushHookVersion: "",
|
|
338
|
+
pushRemoteBlocked: [],
|
|
339
|
+
consentStatus: "enabled",
|
|
340
|
+
firstBootCompletedAt: null,
|
|
341
|
+
});
|
|
342
|
+
const { ensureInstance, STAGENT_HOOK_VERSION } = await import("../bootstrap");
|
|
343
|
+
const result = await ensureInstance(tempDir);
|
|
344
|
+
expect(result.skipped).toBeUndefined();
|
|
345
|
+
const guardrails = getGuardrails();
|
|
346
|
+
expect(guardrails.prePushHookInstalled).toBe(true);
|
|
347
|
+
expect(guardrails.prePushHookVersion).toBe(STAGENT_HOOK_VERSION);
|
|
348
|
+
expect(guardrails.pushRemoteBlocked).toContain("local");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// NOTE: We do not test "single-clone user (STAGENT_DATA_DIR equals default)" at the
|
|
352
|
+
// orchestrator level here because vi.spyOn(os, "homedir") is not possible in ESM —
|
|
353
|
+
// Node's os module exports are non-configurable and cannot be redefined (vitest throws
|
|
354
|
+
// "Cannot redefine property: homedir"). Stubbing STAGENT_DATA_DIR to the real ~/.stagent
|
|
355
|
+
// would pollute the developer's live database, which is also unacceptable.
|
|
356
|
+
//
|
|
357
|
+
// The single-clone path is fully covered at the unit level by
|
|
358
|
+
// src/lib/instance/__tests__/detect.test.ts → "isPrivateInstance" describe block,
|
|
359
|
+
// specifically the test "returns false when STAGENT_DATA_DIR equals default ~/.stagent".
|
|
360
|
+
// That test directly exercises the detect.isPrivateInstance() function that
|
|
361
|
+
// ensureInstanceConfig() delegates to, making an orchestrator-level duplicate redundant.
|
|
362
|
+
});
|