heyspex 0.1.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 +157 -0
- package/bin/hs.mjs +1028 -0
- package/bin/postinstall.mjs +88 -0
- package/dev-factory/lib/fleet-readiness.mjs +274 -0
- package/dev-factory/lib/fleet-state.mjs +250 -0
- package/dev-factory/lib/sufficiency-score.mjs +441 -0
- package/dist/cli/hs-check.mjs +747 -0
- package/dist/cli/hs-claim.mjs +541 -0
- package/dist/cli/hs-connect.mjs +3436 -0
- package/dist/cli/hs-defer.mjs +8455 -0
- package/dist/cli/hs-env.mjs +36845 -0
- package/dist/cli/hs-init.mjs +1145 -0
- package/dist/cli/hs-install.mjs +1390 -0
- package/dist/cli/hs-join.mjs +38553 -0
- package/dist/cli/hs-vault-pull.mjs +7782 -0
- package/dist/cli/hs-vault.mjs +4166 -0
- package/dist/cli/hs-workspace.mjs +2898 -0
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
- package/docs/mcp-client-config.md +200 -0
- package/package.json +124 -0
- package/scripts/check-mcp-freshness.mjs +379 -0
- package/scripts/dev-server-status.mjs +722 -0
- package/scripts/gateway-skills-lib.mjs +421 -0
- package/scripts/lib/console-url-resolver.mjs +152 -0
- package/scripts/lib/dev-mode.mjs +369 -0
- package/scripts/lib/hs-link.mjs +154 -0
- package/scripts/lib/hs-source-commit.mjs +207 -0
- package/scripts/lib/hs-source-log.mjs +366 -0
- package/scripts/lib/hs-source-preflight.mjs +402 -0
- package/scripts/lib/hs-source-push.mjs +307 -0
- package/scripts/lib/hs-source-secret-scan.mjs +263 -0
- package/scripts/lib/standalone-doctor.mjs +89 -0
- package/scripts/vault-id-lib.mjs +173 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
- package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
- package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
- package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
- package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
- package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
- package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
- package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
- package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
- package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
- package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
- package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
- package/vendor/heyspex-mcp/cli.mjs +15754 -0
- package/vendor/heyspex-mcp/config-settings.mjs +419 -0
- package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
- package/vendor/heyspex-mcp/design-tools.mjs +449 -0
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
- package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
- package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
- package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
- package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
- package/vendor/heyspex-mcp/next-review.mjs +548 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
- package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
- package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
- package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
- package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
- package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
- package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
- package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
- package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
- package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
- package/vendor/heyspex-mcp/read-path.mjs +138 -0
- package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
- package/vendor/heyspex-mcp/register-spec.mjs +417 -0
- package/vendor/heyspex-mcp/research-focus.mjs +476 -0
- package/vendor/heyspex-mcp/research-tools.mjs +109 -0
- package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
- package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
- package/vendor/heyspex-mcp/review-focus.mjs +55 -0
- package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
- package/vendor/heyspex-mcp/run-front.mjs +60 -0
- package/vendor/heyspex-mcp/scheduler.mjs +813 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
- package/vendor/heyspex-mcp/split-research.mjs +832 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
- package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
- package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
- package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
- package/vendor/heyspex-mcp/why-now.mjs +64 -0
- package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
|
@@ -0,0 +1,4166 @@
|
|
|
1
|
+
import { createRequire as __hsCreateRequire } from 'node:module';
|
|
2
|
+
const require = __hsCreateRequire(import.meta.url);
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __esm = (fn, res) => function __init() {
|
|
6
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
7
|
+
};
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// dist/cli/._sql-orm-inert.cli-shim.mjs
|
|
14
|
+
var inert, sql, and, eq, ne, isNull, isNotNull, inArray, bigint, bigserial, boolean, foreignKey, index, integer, jsonb, pgEnum, pgTable, primaryKey, text, timestamp, uniqueIndex;
|
|
15
|
+
var init_sql_orm_inert_cli_shim = __esm({
|
|
16
|
+
"dist/cli/._sql-orm-inert.cli-shim.mjs"() {
|
|
17
|
+
"use strict";
|
|
18
|
+
inert = new Proxy(function inert2() {
|
|
19
|
+
return inert2;
|
|
20
|
+
}, {
|
|
21
|
+
get(_target, prop) {
|
|
22
|
+
if (prop === "then" || prop === Symbol.toPrimitive || prop === Symbol.iterator) return void 0;
|
|
23
|
+
return inert;
|
|
24
|
+
},
|
|
25
|
+
apply() {
|
|
26
|
+
return inert;
|
|
27
|
+
},
|
|
28
|
+
construct() {
|
|
29
|
+
return inert;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
sql = inert;
|
|
33
|
+
and = inert;
|
|
34
|
+
eq = inert;
|
|
35
|
+
ne = inert;
|
|
36
|
+
isNull = inert;
|
|
37
|
+
isNotNull = inert;
|
|
38
|
+
inArray = inert;
|
|
39
|
+
bigint = inert;
|
|
40
|
+
bigserial = inert;
|
|
41
|
+
boolean = inert;
|
|
42
|
+
foreignKey = inert;
|
|
43
|
+
index = inert;
|
|
44
|
+
integer = inert;
|
|
45
|
+
jsonb = inert;
|
|
46
|
+
pgEnum = inert;
|
|
47
|
+
pgTable = inert;
|
|
48
|
+
primaryKey = inert;
|
|
49
|
+
text = inert;
|
|
50
|
+
timestamp = inert;
|
|
51
|
+
uniqueIndex = inert;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// dist/cli/._postgres-client.cli-shim.mjs
|
|
56
|
+
function isPostgresConfigured() {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
function getPostgresDb() {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"getPostgresDb() is unavailable in a published install -- this build ships no direct database connection."
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
function inertClient() {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
var isPostgresUsable;
|
|
68
|
+
var init_postgres_client_cli_shim = __esm({
|
|
69
|
+
"dist/cli/._postgres-client.cli-shim.mjs"() {
|
|
70
|
+
"use strict";
|
|
71
|
+
isPostgresUsable = inertClient;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// spex-console/src/lib/db/schema/interfaces.ts
|
|
76
|
+
var SINGLE_USER_ID, DEFAULT_VAULT_ID;
|
|
77
|
+
var init_interfaces = __esm({
|
|
78
|
+
"spex-console/src/lib/db/schema/interfaces.ts"() {
|
|
79
|
+
"use strict";
|
|
80
|
+
SINGLE_USER_ID = "pwher";
|
|
81
|
+
DEFAULT_VAULT_ID = "default";
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// spex-console/src/lib/db/schema/postgres.ts
|
|
86
|
+
var edgeTypeEnum, validationSeverityEnum, outboxOpEnum, specs, tags, edges, wikilinks, aliases, validationIssues, indexMeta, frontmatterFields, eventOutbox, captureSessions, handoffContext, claimEventKindEnum, activeClaimStatusEnum, pendingReasonEnum, focusEventKindEnum, specEventKindEnum, focusPin, captureFocusPin, captureFocusSelection, focusSelection, focusSkips, activeClaims, claimWaiters, claimEvents, focusEvents, focusAreas, focusAreasEvents, specEvents, rateLimitBuckets, userVaults, milestoneSnapshots, modelRegistryEntries, launchIntentStatusEnum, launchIntents, companionRelayPresence, companionSettingsStore, wipLedger, gatherResults, agentSessions, usageEvents, streamEvents, jobStreams, resolutionStatusEnum, resolutionFocusPin, resolutionLedger, workspaces, vaults, accountSettings, vaultGrants, githubInstallations, fleetMachines, fleetTasks, fleetResults, userApiKeys, apiKeys, mcpSessions, pairingCodes, companionSessions, inviteRequests, notifications, webhookIngestions, specSettlements;
|
|
87
|
+
var init_postgres = __esm({
|
|
88
|
+
"spex-console/src/lib/db/schema/postgres.ts"() {
|
|
89
|
+
"use strict";
|
|
90
|
+
init_sql_orm_inert_cli_shim();
|
|
91
|
+
init_sql_orm_inert_cli_shim();
|
|
92
|
+
init_interfaces();
|
|
93
|
+
edgeTypeEnum = pgEnum("edge_type", [
|
|
94
|
+
"blocks",
|
|
95
|
+
"related",
|
|
96
|
+
"parent_child",
|
|
97
|
+
"discovered_from"
|
|
98
|
+
]);
|
|
99
|
+
validationSeverityEnum = pgEnum("validation_severity", ["error", "warning"]);
|
|
100
|
+
outboxOpEnum = pgEnum("outbox_op", ["upsert", "delete"]);
|
|
101
|
+
specs = pgTable(
|
|
102
|
+
"specs",
|
|
103
|
+
{
|
|
104
|
+
id: text("id").notNull(),
|
|
105
|
+
name: text("name").notNull(),
|
|
106
|
+
type: text("type").notNull(),
|
|
107
|
+
status: text("status").notNull(),
|
|
108
|
+
priority: integer("priority"),
|
|
109
|
+
owner: text("owner"),
|
|
110
|
+
created: text("created").notNull(),
|
|
111
|
+
// ISO date YYYY-MM-DD
|
|
112
|
+
updated: text("updated").notNull(),
|
|
113
|
+
// ISO date YYYY-MM-DD
|
|
114
|
+
relPath: text("rel_path").notNull(),
|
|
115
|
+
// Vault-relative POSIX path
|
|
116
|
+
bodyHash: text("body_hash").notNull(),
|
|
117
|
+
// SHA-256 of full file (frontmatter + body) for change detection
|
|
118
|
+
// Canonical authored body — re-added in 0020 ([[HS-TASK-UGUD]]); reverses the 0005 drop.
|
|
119
|
+
// Nullable until the write path ([[HS-TASK-X724]]) always populates it; UGUD backfills
|
|
120
|
+
// existing rows from the disk vault. NO read-path change yet (reads stay on disk until 4PH9).
|
|
121
|
+
bodyText: text("body_text"),
|
|
122
|
+
indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(),
|
|
123
|
+
// NOTE: `body_text` + `body_tsvector` were dropped in migration 0005
|
|
124
|
+
// ([[HS-EPIC-NLOC]]); the `embedding vector(1536)` column was dropped in
|
|
125
|
+
// 0004 (HS-EPIC-VSTR). HeySpex retrieval is lexical-only and LOCAL — no read
|
|
126
|
+
// tier in Neon — per [[retrieval-substrate-lexical-not-embeddings-gate-b-no]].
|
|
127
|
+
// WRT3 §Schema — single-user sentinel today; future multi-user routing.
|
|
128
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
129
|
+
// Index-isolation scope ([[HS-Z2KC-TASK]]): every index-table row is scoped
|
|
130
|
+
// by vault so >1 vault can share one Neon database without colliding on the
|
|
131
|
+
// specs PK or the (rel_path, user_id) unique index. DEFAULT keeps the ALTER
|
|
132
|
+
// safe on populated tables and single-vault installs under one bucket.
|
|
133
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
134
|
+
// WRT3 §Soft-delete — null = active. No hard-delete API is exposed.
|
|
135
|
+
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
|
136
|
+
// Explicit family key (Naming V3 step 1, [[HS-8O69-EPIC-LKF9-relational-family-graph-authoring]]):
|
|
137
|
+
// nullable until the backfill script runs; the indexer writes the frontmatter
|
|
138
|
+
// `family:` field when present, else derives it via `familyOf` (parse
|
|
139
|
+
// fallback from `id`/basename). Nothing should string-parse family from `id`
|
|
140
|
+
// once this column is populated — see `dev-factory/handoffs/NAMING-V3-MIGRATION-BRIEF.md`.
|
|
141
|
+
family: text("family")
|
|
142
|
+
},
|
|
143
|
+
(table) => [
|
|
144
|
+
// Composite PK (vault_id, id): the same HS-XXXX id may exist once PER VAULT.
|
|
145
|
+
primaryKey({ columns: [table.vaultId, table.id] }),
|
|
146
|
+
// SpexConsole main list views filter by type + status
|
|
147
|
+
index("specs_type_status_idx").on(table.type, table.status),
|
|
148
|
+
// Family-graph lookups (Naming V3 step 1) — group specs by family key.
|
|
149
|
+
index("specs_family_idx").on(table.family),
|
|
150
|
+
// Per-user + per-vault lookups + filtering out soft-deleted rows in the hot path
|
|
151
|
+
index("specs_user_active_idx").on(table.userId, table.vaultId).where(sql`${table.deletedAt} IS NULL`),
|
|
152
|
+
// One-file-one-row guarantee: at most one ACTIVE row per (rel_path, user,
|
|
153
|
+
// vault). Without this two rows could share a rel_path under different id
|
|
154
|
+
// forms (bare vs full-slug) and render as a duplicate-key error. Partial
|
|
155
|
+
// (deleted_at IS NULL) so soft-deleted orphans don't block re-indexing. The
|
|
156
|
+
// upsert path soft-deletes any conflicting row first. Scoped by vault so the
|
|
157
|
+
// same rel_path can be indexed independently under two vaults.
|
|
158
|
+
uniqueIndex("specs_relpath_user_uq").on(table.relPath, table.userId, table.vaultId).where(sql`${table.deletedAt} IS NULL`)
|
|
159
|
+
]
|
|
160
|
+
);
|
|
161
|
+
tags = pgTable(
|
|
162
|
+
"tags",
|
|
163
|
+
{
|
|
164
|
+
specId: text("spec_id").notNull(),
|
|
165
|
+
tag: text("tag").notNull(),
|
|
166
|
+
inFrontmatter: boolean("in_frontmatter").notNull(),
|
|
167
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
168
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
|
|
169
|
+
},
|
|
170
|
+
(table) => [
|
|
171
|
+
primaryKey({ columns: [table.vaultId, table.specId, table.tag] }),
|
|
172
|
+
foreignKey({
|
|
173
|
+
columns: [table.vaultId, table.specId],
|
|
174
|
+
foreignColumns: [specs.vaultId, specs.id]
|
|
175
|
+
}).onDelete("cascade")
|
|
176
|
+
]
|
|
177
|
+
);
|
|
178
|
+
edges = pgTable(
|
|
179
|
+
"edges",
|
|
180
|
+
{
|
|
181
|
+
fromId: text("from_id").notNull(),
|
|
182
|
+
toId: text("to_id").notNull(),
|
|
183
|
+
edgeType: edgeTypeEnum("edge_type").notNull().$type(),
|
|
184
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
185
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
|
|
186
|
+
},
|
|
187
|
+
(table) => [
|
|
188
|
+
primaryKey({ columns: [table.vaultId, table.fromId, table.toId, table.edgeType] }),
|
|
189
|
+
foreignKey({
|
|
190
|
+
columns: [table.vaultId, table.fromId],
|
|
191
|
+
foreignColumns: [specs.vaultId, specs.id]
|
|
192
|
+
}).onDelete("cascade"),
|
|
193
|
+
foreignKey({
|
|
194
|
+
columns: [table.vaultId, table.toId],
|
|
195
|
+
foreignColumns: [specs.vaultId, specs.id]
|
|
196
|
+
}).onDelete("cascade"),
|
|
197
|
+
// Forward dependency lookups (what does this spec block?)
|
|
198
|
+
index("edges_from_type_idx").on(table.fromId, table.edgeType),
|
|
199
|
+
// Reverse dependency lookups (what blocks this spec?)
|
|
200
|
+
index("edges_to_type_idx").on(table.toId, table.edgeType)
|
|
201
|
+
]
|
|
202
|
+
);
|
|
203
|
+
wikilinks = pgTable(
|
|
204
|
+
"wikilinks",
|
|
205
|
+
{
|
|
206
|
+
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
|
207
|
+
fromId: text("from_id").notNull(),
|
|
208
|
+
targetBasename: text("target_basename").notNull(),
|
|
209
|
+
resolvedId: text("resolved_id"),
|
|
210
|
+
inFrontmatter: boolean("in_frontmatter").notNull(),
|
|
211
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
212
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
|
|
213
|
+
},
|
|
214
|
+
(table) => [
|
|
215
|
+
// Surrogate PK `id` stays; the composite FKs scope both link ends to the
|
|
216
|
+
// owning vault's specs. resolvedId is nullable, so its composite FK is only
|
|
217
|
+
// enforced when BOTH columns are non-null (MATCH SIMPLE — Postgres default),
|
|
218
|
+
// which lets an unresolved forward reference keep resolvedId NULL.
|
|
219
|
+
foreignKey({
|
|
220
|
+
columns: [table.vaultId, table.fromId],
|
|
221
|
+
foreignColumns: [specs.vaultId, specs.id]
|
|
222
|
+
}).onDelete("cascade"),
|
|
223
|
+
// CAUTION (migration 0049): in the live DB this constraint carries a PG-15+
|
|
224
|
+
// column list — ON DELETE SET NULL ("resolved_id") — because a plain
|
|
225
|
+
// composite SET NULL nulls vault_id too (NOT NULL → 23502 on any delete of
|
|
226
|
+
// a spec with inbound resolved links). Drizzle cannot express the column
|
|
227
|
+
// list, so this declaration is the closest approximation: do NOT let a
|
|
228
|
+
// future drizzle-kit generate "correct" the constraint back to plain
|
|
229
|
+
// SET NULL — that reintroduces the delete-blocking bug.
|
|
230
|
+
foreignKey({
|
|
231
|
+
columns: [table.vaultId, table.resolvedId],
|
|
232
|
+
foreignColumns: [specs.vaultId, specs.id]
|
|
233
|
+
}).onDelete("set null"),
|
|
234
|
+
// Backlink queries: find all specs linking to a target
|
|
235
|
+
index("wikilinks_target_idx").on(table.targetBasename)
|
|
236
|
+
]
|
|
237
|
+
);
|
|
238
|
+
aliases = pgTable(
|
|
239
|
+
"aliases",
|
|
240
|
+
{
|
|
241
|
+
specId: text("spec_id").notNull(),
|
|
242
|
+
alias: text("alias").notNull(),
|
|
243
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
244
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
|
|
245
|
+
},
|
|
246
|
+
(table) => [
|
|
247
|
+
primaryKey({ columns: [table.vaultId, table.specId, table.alias] }),
|
|
248
|
+
foreignKey({
|
|
249
|
+
columns: [table.vaultId, table.specId],
|
|
250
|
+
foreignColumns: [specs.vaultId, specs.id]
|
|
251
|
+
}).onDelete("cascade")
|
|
252
|
+
]
|
|
253
|
+
);
|
|
254
|
+
validationIssues = pgTable(
|
|
255
|
+
"validation_issues",
|
|
256
|
+
{
|
|
257
|
+
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
|
258
|
+
specId: text("spec_id").notNull(),
|
|
259
|
+
severity: validationSeverityEnum("severity").notNull().$type(),
|
|
260
|
+
code: text("code").notNull(),
|
|
261
|
+
// e.g. "unresolved_wikilink", "missing_frontmatter"
|
|
262
|
+
message: text("message").notNull(),
|
|
263
|
+
detectedAt: timestamp("detected_at", { withTimezone: true }).notNull(),
|
|
264
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
265
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
|
|
266
|
+
},
|
|
267
|
+
(table) => [
|
|
268
|
+
// Surrogate PK `id` stays; the composite FK scopes issues to the owning
|
|
269
|
+
// vault's specs.
|
|
270
|
+
foreignKey({
|
|
271
|
+
columns: [table.vaultId, table.specId],
|
|
272
|
+
foreignColumns: [specs.vaultId, specs.id]
|
|
273
|
+
}).onDelete("cascade"),
|
|
274
|
+
// Query issues by spec
|
|
275
|
+
index("validation_issues_spec_idx").on(table.specId),
|
|
276
|
+
// Query issues by severity (e.g., show all errors)
|
|
277
|
+
index("validation_issues_severity_idx").on(table.severity)
|
|
278
|
+
]
|
|
279
|
+
);
|
|
280
|
+
indexMeta = pgTable(
|
|
281
|
+
"index_meta",
|
|
282
|
+
{
|
|
283
|
+
key: text("key").notNull(),
|
|
284
|
+
// e.g. "schema_version", "last_indexed_commit"
|
|
285
|
+
value: text("value").notNull(),
|
|
286
|
+
// Scope indexer state (last_indexed_commit, schema_version, last_rebuild_at,
|
|
287
|
+
// and the progress_relay:<userId> KV) per vault so two vaults keep
|
|
288
|
+
// independent commit pointers. Composite PK (vault_id, key).
|
|
289
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
|
|
290
|
+
},
|
|
291
|
+
(table) => [primaryKey({ columns: [table.vaultId, table.key] })]
|
|
292
|
+
);
|
|
293
|
+
frontmatterFields = pgTable(
|
|
294
|
+
"frontmatter_fields",
|
|
295
|
+
{
|
|
296
|
+
specId: text("spec_id").notNull(),
|
|
297
|
+
field: text("field").notNull(),
|
|
298
|
+
value: jsonb("value"),
|
|
299
|
+
// JSON value for flexible storage
|
|
300
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
301
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
|
|
302
|
+
},
|
|
303
|
+
(table) => [
|
|
304
|
+
primaryKey({ columns: [table.vaultId, table.specId, table.field] }),
|
|
305
|
+
foreignKey({
|
|
306
|
+
columns: [table.vaultId, table.specId],
|
|
307
|
+
foreignColumns: [specs.vaultId, specs.id]
|
|
308
|
+
}).onDelete("cascade")
|
|
309
|
+
]
|
|
310
|
+
);
|
|
311
|
+
eventOutbox = pgTable(
|
|
312
|
+
"event_outbox",
|
|
313
|
+
{
|
|
314
|
+
seq: bigserial("seq", { mode: "number" }).primaryKey(),
|
|
315
|
+
userId: text("user_id").notNull(),
|
|
316
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
|
|
317
|
+
// Additive: `seq` (bigserial) stays the PK; consumers that need to filter
|
|
318
|
+
// by owning vault (e.g. a per-vault snapshot job) can now do so — the
|
|
319
|
+
// global PartyKit / snapshot drains stay unfiltered by design (they drain
|
|
320
|
+
// every vault's outbox).
|
|
321
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
322
|
+
specId: text("spec_id").notNull(),
|
|
323
|
+
op: outboxOpEnum("op").notNull().$type(),
|
|
324
|
+
payload: jsonb("payload").notNull(),
|
|
325
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
326
|
+
publishedAt: timestamp("published_at", { withTimezone: true }),
|
|
327
|
+
snapshottedAt: timestamp("snapshotted_at", { withTimezone: true })
|
|
328
|
+
},
|
|
329
|
+
(table) => [
|
|
330
|
+
// PartyKit fan-out drain — unpublished rows in monotonic order
|
|
331
|
+
index("event_outbox_unpublished_idx").on(table.seq).where(sql`${table.publishedAt} IS NULL`),
|
|
332
|
+
// Snapshot-to-git drain — unsnapshotted rows in monotonic order
|
|
333
|
+
index("event_outbox_unsnapshotted_idx").on(table.seq).where(sql`${table.snapshottedAt} IS NULL`),
|
|
334
|
+
// Per-vault-per-user catch-up (`WHERE vault_id = ? AND user_id = ? AND seq > ?`)
|
|
335
|
+
index("event_outbox_user_seq_idx").on(table.vaultId, table.userId, table.seq)
|
|
336
|
+
]
|
|
337
|
+
);
|
|
338
|
+
captureSessions = pgTable(
|
|
339
|
+
"capture_sessions",
|
|
340
|
+
{
|
|
341
|
+
id: text("id").primaryKey(),
|
|
342
|
+
// HS-CAP-XXXX, minted by the writer
|
|
343
|
+
ownerUserId: text("owner_user_id").notNull(),
|
|
344
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]] of [[HS-TNTI-EPIC-saas-multi-tenant-isolation]]).
|
|
345
|
+
// Mirrors `specs.vault_id` EXACTLY (text, NOT NULL, DEFAULT 'default', no FK
|
|
346
|
+
// — `vaults` rows can be provisioned in Neon after an index row exists, so a
|
|
347
|
+
// hard FK would break the boot order; the id is the isolation key, not an
|
|
348
|
+
// entity reference). The PK stays the globally-minted `id` (capture ids are
|
|
349
|
+
// random tokens, not per-vault sequences); `vault_id` is the READ/WRITE
|
|
350
|
+
// filter so one user's two vaults never see each other's captures.
|
|
351
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
352
|
+
openedBy: text("opened_by").$type().notNull(),
|
|
353
|
+
openedBySession: text("opened_by_session").notNull(),
|
|
354
|
+
mode: text("mode").$type().notNull().default("attended"),
|
|
355
|
+
status: text("status").$type().notNull().default("pending"),
|
|
356
|
+
specPath: text("spec_path"),
|
|
357
|
+
contentBuffer: jsonb("content_buffer").notNull().default(sql`'{}'::jsonb`),
|
|
358
|
+
attachedAgents: jsonb("attached_agents").notNull().default(sql`'[]'::jsonb`),
|
|
359
|
+
captureChannel: text("capture_channel"),
|
|
360
|
+
traceId: text("trace_id"),
|
|
361
|
+
sourceUrl: text("source_url"),
|
|
362
|
+
/**
|
|
363
|
+
* Provenance columns for a capture created from the Agent Mirror canvas
|
|
364
|
+
* ([[HS-1ROV-EPIC-session-mirror-platform-claude-code]] §Stage 6a). Both
|
|
365
|
+
* nullable — every pre-existing writer (modal/agent/companion) leaves
|
|
366
|
+
* them unset. No FK (session/launch rows can expire or predate this
|
|
367
|
+
* column); `agent_session_id` is `agent_sessions.session_id`,
|
|
368
|
+
* `launch_id` is `launch_intents.id` by convention only.
|
|
369
|
+
*/
|
|
370
|
+
agentSessionId: text("agent_session_id"),
|
|
371
|
+
launchId: text("launch_id"),
|
|
372
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
373
|
+
lastActivityAt: timestamp("last_activity_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
374
|
+
materializedAt: timestamp("materialized_at", { withTimezone: true }),
|
|
375
|
+
abandonedAt: timestamp("abandoned_at", { withTimezone: true })
|
|
376
|
+
},
|
|
377
|
+
(table) => [
|
|
378
|
+
// Per-vault + per-user lookup of in-flight captures (e.g. `/hs-capture` attach)
|
|
379
|
+
index("capture_sessions_pending_by_user").on(table.vaultId, table.ownerUserId, table.status).where(sql`${table.status} = 'pending'`),
|
|
380
|
+
// Companion `heyspex.capture/0.1` trace correlation
|
|
381
|
+
index("capture_sessions_companion_by_trace").on(table.traceId).where(sql`${table.traceId} IS NOT NULL`)
|
|
382
|
+
]
|
|
383
|
+
);
|
|
384
|
+
handoffContext = pgTable(
|
|
385
|
+
"handoff_context",
|
|
386
|
+
{
|
|
387
|
+
laneKey: text("lane_key").notNull(),
|
|
388
|
+
// agent/lane identifier — upsert key
|
|
389
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
390
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
|
|
391
|
+
// Part of the widened upsert key below: two vaults must keep independent
|
|
392
|
+
// CURRENT rows for the same (user, lane_key), not fight over one row.
|
|
393
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
394
|
+
agentId: text("agent_id").notNull(),
|
|
395
|
+
specId: text("spec_id"),
|
|
396
|
+
// current spec; plain text, no FK (id-form may be bare vs full-slug)
|
|
397
|
+
phase: text("phase"),
|
|
398
|
+
blockers: jsonb("blockers").notNull().default(sql`'[]'::jsonb`),
|
|
399
|
+
estimatedTime: text("estimated_time"),
|
|
400
|
+
links: jsonb("links").notNull().default(sql`'[]'::jsonb`),
|
|
401
|
+
schemaVersion: integer("schema_version").notNull().default(1),
|
|
402
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
403
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
404
|
+
// TTL — server-set on every upsert; reads filter `expires_at > now()`.
|
|
405
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull()
|
|
406
|
+
},
|
|
407
|
+
(table) => [
|
|
408
|
+
// Self-purge upsert key: one CURRENT row per (vault, user, lane). Widened
|
|
409
|
+
// ([[HS-ZP5H-TASK]] GAP 2) from (user, lane) — two vaults sharing the
|
|
410
|
+
// owner's user_id must not fight over one lane row.
|
|
411
|
+
uniqueIndex("handoff_context_user_lane_uq").on(table.vaultId, table.userId, table.laneKey),
|
|
412
|
+
// List-active-lanes read path (parallel-agent off-on-deck — PA7H).
|
|
413
|
+
index("handoff_context_user_expiry_idx").on(table.vaultId, table.userId, table.expiresAt)
|
|
414
|
+
]
|
|
415
|
+
);
|
|
416
|
+
claimEventKindEnum = pgEnum("claim_event_kind", [
|
|
417
|
+
"acquired",
|
|
418
|
+
"released",
|
|
419
|
+
"expired",
|
|
420
|
+
"stolen",
|
|
421
|
+
"status_updated",
|
|
422
|
+
// Sequencer decision log ([[HS-DO05-4U1E-EPIC-session-sequencer-in-the-console]]
|
|
423
|
+
// AC4): appended in the same transaction as the sequence assignment so the
|
|
424
|
+
// replay epic can reconstruct the order from events alone.
|
|
425
|
+
"sequence_assigned"
|
|
426
|
+
]);
|
|
427
|
+
activeClaimStatusEnum = pgEnum("active_claim_status", [
|
|
428
|
+
"working",
|
|
429
|
+
"pending",
|
|
430
|
+
"blocked"
|
|
431
|
+
]);
|
|
432
|
+
pendingReasonEnum = pgEnum("pending_reason", [
|
|
433
|
+
"question",
|
|
434
|
+
"permission",
|
|
435
|
+
"planning",
|
|
436
|
+
"response"
|
|
437
|
+
]);
|
|
438
|
+
focusEventKindEnum = pgEnum("focus_event_kind", ["set", "cleared"]);
|
|
439
|
+
specEventKindEnum = pgEnum("spec_event_kind", [
|
|
440
|
+
"status_change",
|
|
441
|
+
"wikilink_added",
|
|
442
|
+
"wikilink_removed",
|
|
443
|
+
"review_requested",
|
|
444
|
+
"review_resolved",
|
|
445
|
+
"priority_change",
|
|
446
|
+
"parent_change",
|
|
447
|
+
"claim_acquired",
|
|
448
|
+
"claim_released"
|
|
449
|
+
]);
|
|
450
|
+
focusPin = pgTable(
|
|
451
|
+
"focus_pin",
|
|
452
|
+
{
|
|
453
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]]) — mirrors `specs.vault_id`. The
|
|
454
|
+
// singleton is per (vault, user), not per user: one user with two vaults
|
|
455
|
+
// pins independently in each.
|
|
456
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
457
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
458
|
+
specId: text("spec_id").notNull(),
|
|
459
|
+
pinnedBy: text("pinned_by").notNull(),
|
|
460
|
+
// agent identifier
|
|
461
|
+
pinnedAt: timestamp("pinned_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
462
|
+
// Silent fuse — null means no expiry; reads treat `expires_at <= now()` as cleared.
|
|
463
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
464
|
+
// Cross-milestone "do the prerequisite first, then come back" return target
|
|
465
|
+
// ([[HS-EPIC-QQBD]]): when the spec named by `return_on_close_of` closes, the
|
|
466
|
+
// status route re-pins `return_to_spec_id` and clears both. Both null = an
|
|
467
|
+
// ordinary pin with no auto-return. Nullable + additive (no data backfill).
|
|
468
|
+
returnToSpecId: text("return_to_spec_id"),
|
|
469
|
+
returnOnCloseOf: text("return_on_close_of"),
|
|
470
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
471
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
472
|
+
},
|
|
473
|
+
(table) => [primaryKey({ columns: [table.vaultId, table.userId] })]
|
|
474
|
+
);
|
|
475
|
+
captureFocusPin = pgTable(
|
|
476
|
+
"capture_focus_pin",
|
|
477
|
+
{
|
|
478
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
479
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
480
|
+
specId: text("spec_id").notNull(),
|
|
481
|
+
pinnedBy: text("pinned_by").notNull(),
|
|
482
|
+
pinnedAt: timestamp("pinned_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
483
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
484
|
+
// Kept structurally identical to {@link focusPin} (pins.ts casts one table to
|
|
485
|
+
// the other); the capture flow never populates these return-target columns.
|
|
486
|
+
returnToSpecId: text("return_to_spec_id"),
|
|
487
|
+
returnOnCloseOf: text("return_on_close_of"),
|
|
488
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
489
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
490
|
+
},
|
|
491
|
+
(table) => [primaryKey({ columns: [table.vaultId, table.userId] })]
|
|
492
|
+
);
|
|
493
|
+
captureFocusSelection = pgTable(
|
|
494
|
+
"capture_focus_selection",
|
|
495
|
+
{
|
|
496
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
497
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
498
|
+
// The resolved spec path of the current capture on deck (layer-2 core).
|
|
499
|
+
specPath: text("spec_path"),
|
|
500
|
+
// Capture kind — "idea" today; text, validated app-side, kept open.
|
|
501
|
+
kind: text("kind"),
|
|
502
|
+
// Free-form note for why this capture is pinned (e.g. "Pinned from console").
|
|
503
|
+
reason: text("reason"),
|
|
504
|
+
// The agent display name currently claiming the capture (doc-claim-badge).
|
|
505
|
+
claimedBy: text("claimed_by"),
|
|
506
|
+
// The claiming agent's session id (claim refresh surface).
|
|
507
|
+
claimSession: text("claim_session"),
|
|
508
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
509
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
510
|
+
},
|
|
511
|
+
(table) => [primaryKey({ columns: [table.vaultId, table.userId] })]
|
|
512
|
+
);
|
|
513
|
+
focusSelection = pgTable(
|
|
514
|
+
"focus_selection",
|
|
515
|
+
{
|
|
516
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
517
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
518
|
+
// The resolved spec path of the current focus (layer-2 core; scheduler tier #2).
|
|
519
|
+
specPath: text("spec_path"),
|
|
520
|
+
// Grain: "milestone" | "epic" | "task" (FocusKind). Text, not an enum — the
|
|
521
|
+
// vocabulary is validated app-side and kept open for forward grains.
|
|
522
|
+
kind: text("kind"),
|
|
523
|
+
// Provenance: "human" | "algorithm" | "human_switched_from_blocker" (FocusProvenance).
|
|
524
|
+
pickedBy: text("picked_by"),
|
|
525
|
+
pickedAt: timestamp("picked_at", { withTimezone: true }),
|
|
526
|
+
// The FocusReason object (trigger / formula / score / runners_up[]) as JSONB.
|
|
527
|
+
pickedReason: jsonb("picked_reason"),
|
|
528
|
+
// The blocked epic switched away from (only when picked_by = human_switched_from_blocker).
|
|
529
|
+
blockerOrigin: text("blocker_origin"),
|
|
530
|
+
// Free-form note when a human overrides the algorithm pick (Why-Now panel).
|
|
531
|
+
overrideReason: text("override_reason"),
|
|
532
|
+
// Legacy FocusState fields kept for backcompat with pre-Phase-3 writers.
|
|
533
|
+
reason: text("reason"),
|
|
534
|
+
expires: timestamp("expires", { withTimezone: true }),
|
|
535
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
536
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
537
|
+
},
|
|
538
|
+
(table) => [primaryKey({ columns: [table.vaultId, table.userId] })]
|
|
539
|
+
);
|
|
540
|
+
focusSkips = pgTable(
|
|
541
|
+
"focus_skips",
|
|
542
|
+
{
|
|
543
|
+
// Insertion-order key — reads order by this so the skip set keeps file order.
|
|
544
|
+
id: bigserial("id", { mode: "number" }).notNull().unique(),
|
|
545
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]]) — mirrors `specs.vault_id`.
|
|
546
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
547
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
548
|
+
specId: text("spec_id").notNull(),
|
|
549
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
550
|
+
},
|
|
551
|
+
(table) => [
|
|
552
|
+
// PK widened to (vault_id, user_id, spec_id): the same HS-XXXX id exists once
|
|
553
|
+
// PER VAULT, so a skip in vault A must not collide with (or hide) the same id
|
|
554
|
+
// in vault B. Strict superset of the old (user_id, spec_id) key — existing
|
|
555
|
+
// rows stay unique under it.
|
|
556
|
+
primaryKey({ columns: [table.vaultId, table.userId, table.specId] }),
|
|
557
|
+
// "this vault+user's skips in insertion order" — the list query.
|
|
558
|
+
index("focus_skips_user_order_idx").on(table.vaultId, table.userId, table.id)
|
|
559
|
+
]
|
|
560
|
+
);
|
|
561
|
+
activeClaims = pgTable(
|
|
562
|
+
"active_claims",
|
|
563
|
+
{
|
|
564
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]]) — mirrors `specs.vault_id`. Part
|
|
565
|
+
// of the PK, so the atomic-acquire UNIQUE is per (vault, user, spec): vault A
|
|
566
|
+
// holding HS-XXXX no longer blocks vault B from claiming ITS OWN HS-XXXX.
|
|
567
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
568
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
569
|
+
specId: text("spec_id").notNull(),
|
|
570
|
+
agentId: text("agent_id").notNull(),
|
|
571
|
+
sessionId: text("session_id").notNull(),
|
|
572
|
+
claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
573
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
574
|
+
// Live agent-lifecycle signals — mirror the file-side `ActiveClaim` shape from
|
|
575
|
+
// [[HS-TASK-0LU3]] so the On-Deck v2 surface can distinguish Working / Pending /
|
|
576
|
+
// Stale / Blocked when claims migrate off `.heyspex/active-claims.json` ([[HS-TASK-ACLM]]).
|
|
577
|
+
// `effectiveStatus` + `staleAt` stay derived at read time (not persisted).
|
|
578
|
+
status: activeClaimStatusEnum("status").notNull().default("working"),
|
|
579
|
+
lastActivityAt: timestamp("last_activity_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
580
|
+
/** Fast-death heartbeat ([[HS-TASK-A3FE]]); distinct from minutes-scale `expires_at` lease. */
|
|
581
|
+
heartbeatAt: timestamp("heartbeat_at", { withTimezone: true }),
|
|
582
|
+
pendingReason: pendingReasonEnum("pending_reason"),
|
|
583
|
+
pendingExcerpt: text("pending_excerpt"),
|
|
584
|
+
errorMessage: text("error_message"),
|
|
585
|
+
// Live agent work-stage ([[HS-EPIC-... What's Next Phase 2]]): the
|
|
586
|
+
// present-progressive verb the agent reports as it works (one of WORK_STAGES,
|
|
587
|
+
// e.g. "Drafting") and the 0-based acceptance-criterion index it is currently
|
|
588
|
+
// on. Both nullable — a claim need not report progress. Plain text/integer (not
|
|
589
|
+
// a pg enum) so the verb vocabulary can evolve without an enum migration; the
|
|
590
|
+
// closed list is enforced in code (src/lib/work-stages.ts) at the write route.
|
|
591
|
+
currentStage: text("current_stage"),
|
|
592
|
+
acProgress: integer("ac_progress"),
|
|
593
|
+
// Durable agent identity↔session binding ([[HS-EPIC-96YK]] AC#2): the
|
|
594
|
+
// identity `name` slug this claim is operating as (e.g. "code-reviewer"),
|
|
595
|
+
// so the Agents surface binds presence to the named identity instead of the
|
|
596
|
+
// best-effort agent-string match. Nullable — a claim need not declare one.
|
|
597
|
+
agentProfile: text("agent_profile"),
|
|
598
|
+
// Close-out verification gate ([[HS-EPIC-P2TR]]). `close_permitted` blocks the
|
|
599
|
+
// `in_review` flip until an independent verifier (or the deterministic manifest
|
|
600
|
+
// runner) grants it via the verification API — the implementer's self-reported
|
|
601
|
+
// `ac_progress` is demoted to a display-only claim. `close_granted_by` records
|
|
602
|
+
// the granting authority (agent id or "human"); `verification_ac_state` holds
|
|
603
|
+
// the per-criterion `AcCriterionState[]` provenance array.
|
|
604
|
+
closePermitted: boolean("close_permitted").notNull().default(false),
|
|
605
|
+
closeGrantedAt: timestamp("close_granted_at", { withTimezone: true }),
|
|
606
|
+
closeGrantedBy: text("close_granted_by"),
|
|
607
|
+
verificationAcState: jsonb("verification_ac_state").$type().notNull().default(sql`'[]'::jsonb`),
|
|
608
|
+
// Session-sequencer global order ([[HS-DO05-4U1E-EPIC-session-sequencer-in-the-console]] AC1):
|
|
609
|
+
// database-minted, strictly increasing across the table — therefore strictly
|
|
610
|
+
// increasing within every vault (gaps allowed; ordering is the contract).
|
|
611
|
+
sequence: bigserial("sequence", { mode: "number" }).notNull().unique(),
|
|
612
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
613
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
614
|
+
},
|
|
615
|
+
(table) => [primaryKey({ columns: [table.vaultId, table.userId, table.specId] })]
|
|
616
|
+
);
|
|
617
|
+
claimWaiters = pgTable(
|
|
618
|
+
"claim_waiters",
|
|
619
|
+
{
|
|
620
|
+
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
621
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
622
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
623
|
+
specId: text("spec_id").notNull(),
|
|
624
|
+
agentId: text("agent_id").notNull(),
|
|
625
|
+
sessionId: text("session_id").notNull(),
|
|
626
|
+
// Shared global order: DEFAULT nextval on the claims sequence, not a
|
|
627
|
+
// per-table bigserial (which would mint a second, unrelated counter).
|
|
628
|
+
sequence: bigint("sequence", { mode: "number" }).notNull().default(sql`nextval('active_claims_sequence_seq')`),
|
|
629
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
630
|
+
},
|
|
631
|
+
(table) => [
|
|
632
|
+
// "who is queued behind this spec, in order" — the grant + position query.
|
|
633
|
+
index("claim_waiters_spec_seq_idx").on(
|
|
634
|
+
table.vaultId,
|
|
635
|
+
table.userId,
|
|
636
|
+
table.specId,
|
|
637
|
+
table.sequence
|
|
638
|
+
)
|
|
639
|
+
]
|
|
640
|
+
);
|
|
641
|
+
claimEvents = pgTable(
|
|
642
|
+
"claim_events",
|
|
643
|
+
{
|
|
644
|
+
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
645
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
646
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
|
|
647
|
+
// Additive: the `id` PK is untouched (append-only log, no upsert key).
|
|
648
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
649
|
+
eventKind: claimEventKindEnum("event_kind").notNull(),
|
|
650
|
+
specId: text("spec_id").notNull(),
|
|
651
|
+
agentId: text("agent_id").notNull(),
|
|
652
|
+
timestamp: timestamp("timestamp", { withTimezone: true }).notNull().default(sql`now()`),
|
|
653
|
+
metadata: jsonb("metadata").notNull().default(sql`'{}'::jsonb`),
|
|
654
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
655
|
+
},
|
|
656
|
+
(table) => [
|
|
657
|
+
// "events for this spec, newest first" — the common query (NCPS §indexes).
|
|
658
|
+
index("claim_events_spec_ts_idx").on(
|
|
659
|
+
table.vaultId,
|
|
660
|
+
table.userId,
|
|
661
|
+
table.specId,
|
|
662
|
+
table.timestamp.desc()
|
|
663
|
+
)
|
|
664
|
+
]
|
|
665
|
+
);
|
|
666
|
+
focusEvents = pgTable(
|
|
667
|
+
"focus_events",
|
|
668
|
+
{
|
|
669
|
+
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
670
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
671
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
|
|
672
|
+
// Additive: the `id` PK is untouched (append-only log, no upsert key).
|
|
673
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
674
|
+
eventKind: focusEventKindEnum("event_kind").notNull(),
|
|
675
|
+
specId: text("spec_id"),
|
|
676
|
+
// null for `cleared`
|
|
677
|
+
actor: text("actor").notNull(),
|
|
678
|
+
timestamp: timestamp("timestamp", { withTimezone: true }).notNull().default(sql`now()`),
|
|
679
|
+
metadata: jsonb("metadata").notNull().default(sql`'{}'::jsonb`),
|
|
680
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
681
|
+
},
|
|
682
|
+
(table) => [
|
|
683
|
+
index("focus_events_spec_ts_idx").on(
|
|
684
|
+
table.vaultId,
|
|
685
|
+
table.userId,
|
|
686
|
+
table.specId,
|
|
687
|
+
table.timestamp.desc()
|
|
688
|
+
)
|
|
689
|
+
]
|
|
690
|
+
);
|
|
691
|
+
focusAreas = pgTable(
|
|
692
|
+
"focus_areas",
|
|
693
|
+
{
|
|
694
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]]) — mirrors `specs.vault_id`. The
|
|
695
|
+
// band is per (vault, user): a tag band set in vault A must not lift specs
|
|
696
|
+
// in vault B.
|
|
697
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
698
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
699
|
+
version: integer("version").notNull().default(1),
|
|
700
|
+
rationale: text("rationale"),
|
|
701
|
+
// The tag set the focus-band pipeline stage matches against (jsonb string[]).
|
|
702
|
+
tags: jsonb("tags").notNull().default(sql`'[]'::jsonb`),
|
|
703
|
+
updatedBy: text("updated_by"),
|
|
704
|
+
// agent · session that last wrote the band
|
|
705
|
+
// Reserved fuse — null means no expiry. A2 writes it; enforcement is deferred.
|
|
706
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
707
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
708
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
709
|
+
},
|
|
710
|
+
(table) => [primaryKey({ columns: [table.vaultId, table.userId] })]
|
|
711
|
+
);
|
|
712
|
+
focusAreasEvents = pgTable(
|
|
713
|
+
"focus_areas_events",
|
|
714
|
+
{
|
|
715
|
+
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
716
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
717
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
|
|
718
|
+
// Additive: the `id` PK is untouched (append-only log, no upsert key).
|
|
719
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
720
|
+
eventKind: focusEventKindEnum("event_kind").notNull(),
|
|
721
|
+
actor: text("actor").notNull(),
|
|
722
|
+
timestamp: timestamp("timestamp", { withTimezone: true }).notNull().default(sql`now()`),
|
|
723
|
+
metadata: jsonb("metadata").notNull().default(sql`'{}'::jsonb`),
|
|
724
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
725
|
+
},
|
|
726
|
+
(table) => [
|
|
727
|
+
index("focus_areas_events_ts_idx").on(table.vaultId, table.userId, table.timestamp.desc())
|
|
728
|
+
]
|
|
729
|
+
);
|
|
730
|
+
specEvents = pgTable(
|
|
731
|
+
"spec_events",
|
|
732
|
+
{
|
|
733
|
+
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
734
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
735
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
|
|
736
|
+
// Additive: the `id` PK is untouched (append-only log, no upsert key).
|
|
737
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
738
|
+
eventKind: specEventKindEnum("event_kind").notNull(),
|
|
739
|
+
specId: text("spec_id").notNull(),
|
|
740
|
+
actor: text("actor").notNull(),
|
|
741
|
+
timestamp: timestamp("timestamp", { withTimezone: true }).notNull().default(sql`now()`),
|
|
742
|
+
metadata: jsonb("metadata").notNull().default(sql`'{}'::jsonb`),
|
|
743
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
744
|
+
},
|
|
745
|
+
(table) => [
|
|
746
|
+
// High-traffic "events for spec X" query (<50ms target — NCPS AC #5).
|
|
747
|
+
index("spec_events_spec_ts_idx").on(
|
|
748
|
+
table.vaultId,
|
|
749
|
+
table.userId,
|
|
750
|
+
table.specId,
|
|
751
|
+
table.timestamp.desc()
|
|
752
|
+
),
|
|
753
|
+
// `?kind=` filtered query.
|
|
754
|
+
index("spec_events_spec_kind_ts_idx").on(
|
|
755
|
+
table.vaultId,
|
|
756
|
+
table.userId,
|
|
757
|
+
table.specId,
|
|
758
|
+
table.eventKind,
|
|
759
|
+
table.timestamp.desc()
|
|
760
|
+
)
|
|
761
|
+
]
|
|
762
|
+
);
|
|
763
|
+
rateLimitBuckets = pgTable(
|
|
764
|
+
"rate_limit_buckets",
|
|
765
|
+
{
|
|
766
|
+
userId: text("user_id").notNull(),
|
|
767
|
+
bucket: text("bucket").notNull(),
|
|
768
|
+
windowStart: timestamp("window_start", { withTimezone: true }).notNull(),
|
|
769
|
+
count: integer("count").notNull().default(0),
|
|
770
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
771
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
772
|
+
},
|
|
773
|
+
(table) => [
|
|
774
|
+
primaryKey({ columns: [table.userId, table.bucket, table.windowStart] }),
|
|
775
|
+
// Background reaper / dashboards: enumerate stale rows by `window_start`
|
|
776
|
+
// older than the longest live window. The hot path uses the composite PK.
|
|
777
|
+
index("rate_limit_buckets_window_idx").on(table.windowStart)
|
|
778
|
+
]
|
|
779
|
+
);
|
|
780
|
+
userVaults = pgTable("user_vaults", {
|
|
781
|
+
userId: text("user_id").primaryKey(),
|
|
782
|
+
vaultRepo: text("vault_repo").notNull(),
|
|
783
|
+
/** GitHub App installation id — set by `/api/github/callback` before a vault is linked. */
|
|
784
|
+
installationId: integer("installation_id"),
|
|
785
|
+
vaultLocalPath: text("vault_local_path"),
|
|
786
|
+
repoName: text("repo_name"),
|
|
787
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
788
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
789
|
+
});
|
|
790
|
+
milestoneSnapshots = pgTable(
|
|
791
|
+
"milestone_snapshots",
|
|
792
|
+
{
|
|
793
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]]) — mirrors `specs.vault_id`. Part
|
|
794
|
+
// of the PK: two vaults each hold their own frozen rank for the same
|
|
795
|
+
// milestone id.
|
|
796
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
797
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
798
|
+
milestoneId: text("milestone_id").notNull(),
|
|
799
|
+
snapshotVersion: integer("snapshot_version").notNull().default(1),
|
|
800
|
+
snapshotAt: timestamp("snapshot_at", { withTimezone: true }).notNull().defaultNow(),
|
|
801
|
+
schedulerInputDigest: text("scheduler_input_digest"),
|
|
802
|
+
epicOrder: jsonb("epic_order").$type().notNull(),
|
|
803
|
+
appendedAfterSnapshot: jsonb("appended_after_snapshot").$type().notNull().default(sql`'{"epics":[],"tasks_by_epic":{}}'::jsonb`),
|
|
804
|
+
dormantAt: timestamp("dormant_at", { withTimezone: true })
|
|
805
|
+
},
|
|
806
|
+
(table) => [
|
|
807
|
+
primaryKey({ columns: [table.vaultId, table.userId, table.milestoneId] }),
|
|
808
|
+
// "active snapshot for vault+user" hot lookup — only non-dormant rows.
|
|
809
|
+
index("ms_user_active_idx").on(table.vaultId, table.userId).where(sql`${table.dormantAt} IS NULL`)
|
|
810
|
+
]
|
|
811
|
+
);
|
|
812
|
+
modelRegistryEntries = pgTable(
|
|
813
|
+
"model_registry_entries",
|
|
814
|
+
{
|
|
815
|
+
id: text("id").notNull(),
|
|
816
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
817
|
+
model: text("model").notNull(),
|
|
818
|
+
agent: text("agent").notNull(),
|
|
819
|
+
shell: text("shell").notNull(),
|
|
820
|
+
enabled: boolean("enabled").notNull().default(true),
|
|
821
|
+
isDefault: boolean("is_default").notNull().default(false),
|
|
822
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
823
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
824
|
+
},
|
|
825
|
+
(table) => [
|
|
826
|
+
primaryKey({ columns: [table.userId, table.id] }),
|
|
827
|
+
index("model_registry_entries_user_default_idx").on(table.userId, table.isDefault)
|
|
828
|
+
]
|
|
829
|
+
);
|
|
830
|
+
launchIntentStatusEnum = pgEnum("launch_intent_status", [
|
|
831
|
+
"pending",
|
|
832
|
+
// emitted by the browser, not yet claimed by a companion
|
|
833
|
+
"claimed",
|
|
834
|
+
// a companion poller took it (claim-on-read) and is spawning
|
|
835
|
+
"done",
|
|
836
|
+
// the companion ACKed after spawning the local agent
|
|
837
|
+
"failed"
|
|
838
|
+
// the companion claimed it but the local spawn threw (see last_error)
|
|
839
|
+
]);
|
|
840
|
+
launchIntents = pgTable(
|
|
841
|
+
"launch_intents",
|
|
842
|
+
{
|
|
843
|
+
id: text("id").primaryKey(),
|
|
844
|
+
// uuid, client-opaque intent id
|
|
845
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
846
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
|
|
847
|
+
// Additive: `id` (a caller-minted uuid) stays the PK; `vault_id` is the
|
|
848
|
+
// READ/WRITE filter so the SAME owner's two vaults never drain each
|
|
849
|
+
// other's spawn/mint/mirror-send intents.
|
|
850
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
851
|
+
// The `/hs-next <id>` target spec (idempotency key for the optimistic
|
|
852
|
+
// "Launching…" face); null for a bare `/hs-next` launch.
|
|
853
|
+
specId: text("spec_id"),
|
|
854
|
+
prompt: text("prompt").notNull(),
|
|
855
|
+
// the `/hs-next …` prompt to seed the agent
|
|
856
|
+
registryEntryId: text("registry_entry_id"),
|
|
857
|
+
agent: text("agent"),
|
|
858
|
+
/** When false, companion uses `cmd /c` on Windows so the tab closes when the agent exits. */
|
|
859
|
+
keepTerminalOpen: boolean("keep_terminal_open").notNull().default(true),
|
|
860
|
+
// Resolved launch target ([[HS-EPIC-JLTL]] model+effort assignment). Nullable
|
|
861
|
+
// and additive: an old companion simply ignores them and spawns with CLI
|
|
862
|
+
// defaults — exactly the pre-column behavior.
|
|
863
|
+
model: text("model"),
|
|
864
|
+
reasoningEffort: text("reasoning_effort"),
|
|
865
|
+
// Companion-reported spawn failure detail (status `failed`) — the write-back
|
|
866
|
+
// channel that lets the cloud UI stop toasting optimistic success.
|
|
867
|
+
lastError: text("last_error"),
|
|
868
|
+
status: launchIntentStatusEnum("status").notNull().default("pending"),
|
|
869
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
870
|
+
// Set when a companion claims the intent (pending → claimed).
|
|
871
|
+
claimedAt: timestamp("claimed_at", { withTimezone: true }),
|
|
872
|
+
// TTL fuse — server-set on emit; the drain filters `expires_at > now()` and
|
|
873
|
+
// emit reaps already-expired rows so a dead browser tab can't strand work.
|
|
874
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull()
|
|
875
|
+
},
|
|
876
|
+
(table) => [
|
|
877
|
+
// Drain query: "oldest pending (non-expired) intents for this vault+user".
|
|
878
|
+
index("launch_intents_user_status_idx").on(
|
|
879
|
+
table.vaultId,
|
|
880
|
+
table.userId,
|
|
881
|
+
table.status,
|
|
882
|
+
table.createdAt
|
|
883
|
+
)
|
|
884
|
+
]
|
|
885
|
+
);
|
|
886
|
+
companionRelayPresence = pgTable(
|
|
887
|
+
"companion_relay_presence",
|
|
888
|
+
{
|
|
889
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
890
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
891
|
+
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull()
|
|
892
|
+
},
|
|
893
|
+
(table) => [primaryKey({ columns: [table.userId, table.vaultId] })]
|
|
894
|
+
);
|
|
895
|
+
companionSettingsStore = pgTable(
|
|
896
|
+
"companion_settings",
|
|
897
|
+
{
|
|
898
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
899
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
900
|
+
settings: jsonb("settings").notNull(),
|
|
901
|
+
telemetry: jsonb("telemetry"),
|
|
902
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
903
|
+
},
|
|
904
|
+
(table) => [primaryKey({ columns: [table.userId, table.vaultId] })]
|
|
905
|
+
);
|
|
906
|
+
wipLedger = pgTable(
|
|
907
|
+
"wip_ledger",
|
|
908
|
+
{
|
|
909
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
910
|
+
/** Epic/spec id the hand-off belongs to (short id, e.g. "HS-COYG"). */
|
|
911
|
+
specId: text("spec_id").notNull(),
|
|
912
|
+
/** The full WipEntry blob — see `WipEntry` in `src/lib/wip-state.ts`. */
|
|
913
|
+
payload: jsonb("payload").notNull(),
|
|
914
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
915
|
+
},
|
|
916
|
+
(table) => [primaryKey({ columns: [table.vaultId, table.specId] })]
|
|
917
|
+
);
|
|
918
|
+
gatherResults = pgTable(
|
|
919
|
+
"gather_results",
|
|
920
|
+
{
|
|
921
|
+
jobId: text("job_id").notNull(),
|
|
922
|
+
idempotencyKey: text("idempotency_key").notNull(),
|
|
923
|
+
/** Per-page result entries — `[{ url, state: "cleaned"|"blocked"|"failed", ... }]`. */
|
|
924
|
+
pages: jsonb("pages").notNull(),
|
|
925
|
+
/**
|
|
926
|
+
* A research job's engine-level search account — `{ query,
|
|
927
|
+
* enginesAttempted, enginesAnswered, resultsFound, resultsGathered,
|
|
928
|
+
* summary, engines: [{ engine, answered, results, reason?, httpStatus? }] }`.
|
|
929
|
+
* Null for plain gather/crawl jobs (nothing was searched) and for uploads
|
|
930
|
+
* from Companions built before the report existed. This is what lets a
|
|
931
|
+
* zero-page search result say WHY it is empty
|
|
932
|
+
* ([[HS-RIZN-DIL5-DIAG-spexcrawl-search-returns-zero-pages-on]]).
|
|
933
|
+
*/
|
|
934
|
+
search: jsonb("search"),
|
|
935
|
+
createdAt: timestamp("created_at", { withTimezone: true }).default(sql`now()`)
|
|
936
|
+
},
|
|
937
|
+
(table) => [primaryKey({ columns: [table.jobId, table.idempotencyKey] })]
|
|
938
|
+
);
|
|
939
|
+
agentSessions = pgTable(
|
|
940
|
+
"agent_sessions",
|
|
941
|
+
{
|
|
942
|
+
sessionId: text("session_id").primaryKey(),
|
|
943
|
+
// FK-by-convention to launch_intents.id — no hard FK, intents expire/sweep
|
|
944
|
+
// while the session row (and its usage history) must outlive them.
|
|
945
|
+
launchId: text("launch_id"),
|
|
946
|
+
ownerUserId: text("owner_user_id").notNull().default(SINGLE_USER_ID),
|
|
947
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]]) — mirrors `specs.vault_id`. The
|
|
948
|
+
// PK stays the vendor-minted `session_id` (a globally-unique uuid, not a
|
|
949
|
+
// per-vault sequence); `vault_id` is the READ/WRITE filter.
|
|
950
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
951
|
+
vendor: text("vendor").notNull(),
|
|
952
|
+
// "claude-code" now; enum grows later (read-only viewer vendors)
|
|
953
|
+
machine: text("machine"),
|
|
954
|
+
cwd: text("cwd"),
|
|
955
|
+
transcriptPath: text("transcript_path"),
|
|
956
|
+
// Session-chain lineage after /clear, /branch, /rewind, --fork-session.
|
|
957
|
+
parentSessionId: text("parent_session_id"),
|
|
958
|
+
status: text("status").notNull().default("live"),
|
|
959
|
+
// live | ended | stale
|
|
960
|
+
startedAt: timestamp("started_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
961
|
+
endedAt: timestamp("ended_at", { withTimezone: true }),
|
|
962
|
+
lastEventAt: timestamp("last_event_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
963
|
+
},
|
|
964
|
+
(table) => [
|
|
965
|
+
// Launch drill-down: "sessions bound to this launch".
|
|
966
|
+
index("agent_sessions_launch_idx").on(table.launchId),
|
|
967
|
+
// "Live sessions on this machine" attach flow, scoped per vault.
|
|
968
|
+
index("agent_sessions_owner_status_idx").on(table.vaultId, table.ownerUserId, table.status)
|
|
969
|
+
]
|
|
970
|
+
);
|
|
971
|
+
usageEvents = pgTable(
|
|
972
|
+
"usage_events",
|
|
973
|
+
{
|
|
974
|
+
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
975
|
+
sessionId: text("session_id").notNull(),
|
|
976
|
+
launchId: text("launch_id"),
|
|
977
|
+
requestId: text("request_id").notNull(),
|
|
978
|
+
model: text("model").notNull(),
|
|
979
|
+
lane: text("lane").notNull(),
|
|
980
|
+
// subscription | api | bundled_local
|
|
981
|
+
inputTokens: integer("input_tokens").notNull(),
|
|
982
|
+
outputTokens: integer("output_tokens").notNull(),
|
|
983
|
+
cacheReadTokens: integer("cache_read_tokens").notNull(),
|
|
984
|
+
cacheCreationTokens: integer("cache_creation_tokens").notNull(),
|
|
985
|
+
cacheCreation5m: integer("cache_creation_5m"),
|
|
986
|
+
cacheCreation1h: integer("cache_creation_1h"),
|
|
987
|
+
isSidechain: boolean("is_sidechain").notNull().default(false),
|
|
988
|
+
at: timestamp("at", { withTimezone: true }).notNull()
|
|
989
|
+
},
|
|
990
|
+
(table) => [
|
|
991
|
+
// Keep-last dedupe key — the ingest route upserts on this so a re-POST of
|
|
992
|
+
// the same request_id overwrites rather than duplicates.
|
|
993
|
+
uniqueIndex("usage_events_session_request_idx").on(table.sessionId, table.requestId),
|
|
994
|
+
// Launch drill-down: "usage rows for this launch, in order".
|
|
995
|
+
index("usage_events_launch_at_idx").on(table.launchId, table.at)
|
|
996
|
+
]
|
|
997
|
+
);
|
|
998
|
+
streamEvents = pgTable(
|
|
999
|
+
"stream_events",
|
|
1000
|
+
{
|
|
1001
|
+
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
1002
|
+
streamId: text("stream_id").notNull(),
|
|
1003
|
+
// "mirror_session" now; "build" | "api" | "council" join later (HS-31Z5).
|
|
1004
|
+
streamKind: text("stream_kind").notNull(),
|
|
1005
|
+
// Producer-assigned dedupe key — stable across retries/transport hot-swaps.
|
|
1006
|
+
eventUuid: text("event_uuid").notNull(),
|
|
1007
|
+
eventType: text("event_type").notNull(),
|
|
1008
|
+
// e.g. "turn" | "status"
|
|
1009
|
+
payload: jsonb("payload").notNull(),
|
|
1010
|
+
at: timestamp("at", { withTimezone: true }).notNull(),
|
|
1011
|
+
// Short-retention TTL fuse — default now()+6h at insert; swept by cron.
|
|
1012
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull()
|
|
1013
|
+
},
|
|
1014
|
+
(table) => [
|
|
1015
|
+
// Producer dedupe — replays/hot-swaps land exactly once per (stream, uuid).
|
|
1016
|
+
uniqueIndex("stream_events_stream_uuid_idx").on(table.streamId, table.eventUuid),
|
|
1017
|
+
// Snapshot-on-connect: "rows for this stream, in order, after a cursor".
|
|
1018
|
+
index("stream_events_stream_id_idx").on(table.streamId, table.id)
|
|
1019
|
+
]
|
|
1020
|
+
);
|
|
1021
|
+
jobStreams = pgTable(
|
|
1022
|
+
"job_streams",
|
|
1023
|
+
{
|
|
1024
|
+
jobId: text("job_id").primaryKey(),
|
|
1025
|
+
kind: text("kind").notNull(),
|
|
1026
|
+
// build | api | council
|
|
1027
|
+
// FK-by-convention to launch_intents.id — no hard FK, same reasoning as
|
|
1028
|
+
// agent_sessions.launch_id (intents expire/sweep, the job row must outlive them).
|
|
1029
|
+
launchId: text("launch_id"),
|
|
1030
|
+
provider: text("provider"),
|
|
1031
|
+
// e.g. "deepseek" — the tile's `source`
|
|
1032
|
+
model: text("model"),
|
|
1033
|
+
seat: text("seat"),
|
|
1034
|
+
// routing-table seat name (dev-factory/routing-table.json)
|
|
1035
|
+
machine: text("machine"),
|
|
1036
|
+
status: text("status").notNull().default("queued"),
|
|
1037
|
+
// queued | running | done | failed
|
|
1038
|
+
startedAt: timestamp("started_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1039
|
+
endedAt: timestamp("ended_at", { withTimezone: true }),
|
|
1040
|
+
lastEventAt: timestamp("last_event_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
1041
|
+
},
|
|
1042
|
+
(table) => [
|
|
1043
|
+
// "Live-ish jobs" read for the unioned stream list.
|
|
1044
|
+
index("job_streams_status_idx").on(table.status),
|
|
1045
|
+
// Launch drill-down: "job streams bound to this launch".
|
|
1046
|
+
index("job_streams_launch_idx").on(table.launchId)
|
|
1047
|
+
]
|
|
1048
|
+
);
|
|
1049
|
+
resolutionStatusEnum = pgEnum("resolution_status", [
|
|
1050
|
+
"open",
|
|
1051
|
+
"resolved",
|
|
1052
|
+
"dismissed",
|
|
1053
|
+
"deferred"
|
|
1054
|
+
]);
|
|
1055
|
+
resolutionFocusPin = pgTable(
|
|
1056
|
+
"resolution_focus_pin",
|
|
1057
|
+
{
|
|
1058
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
1059
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
1060
|
+
itemKey: text("item_key").notNull(),
|
|
1061
|
+
pinnedBy: text("pinned_by").notNull(),
|
|
1062
|
+
pinnedAt: timestamp("pinned_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1063
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
1064
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1065
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
1066
|
+
},
|
|
1067
|
+
(table) => [primaryKey({ columns: [table.vaultId, table.userId] })]
|
|
1068
|
+
);
|
|
1069
|
+
resolutionLedger = pgTable(
|
|
1070
|
+
"resolution_ledger",
|
|
1071
|
+
{
|
|
1072
|
+
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
1073
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
1074
|
+
// Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
|
|
1075
|
+
// Additive: the `id` PK is untouched (append-only log, no upsert key).
|
|
1076
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
1077
|
+
itemKey: text("item_key").notNull(),
|
|
1078
|
+
status: resolutionStatusEnum("status").$type().notNull(),
|
|
1079
|
+
reason: text("reason"),
|
|
1080
|
+
deferUntil: timestamp("defer_until", { withTimezone: true }),
|
|
1081
|
+
at: timestamp("at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1082
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
1083
|
+
},
|
|
1084
|
+
(table) => [
|
|
1085
|
+
// Fold-on-read hot path: newest status row per (vault, user, item_key).
|
|
1086
|
+
index("resolution_ledger_user_item_at_idx").on(
|
|
1087
|
+
table.vaultId,
|
|
1088
|
+
table.userId,
|
|
1089
|
+
table.itemKey,
|
|
1090
|
+
table.at.desc()
|
|
1091
|
+
)
|
|
1092
|
+
]
|
|
1093
|
+
);
|
|
1094
|
+
workspaces = pgTable("workspaces", {
|
|
1095
|
+
id: text("id").primaryKey(),
|
|
1096
|
+
accountId: text("account_id").notNull(),
|
|
1097
|
+
displayName: text("display_name").notNull(),
|
|
1098
|
+
githubOrg: text("github_org"),
|
|
1099
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1100
|
+
/**
|
|
1101
|
+
* Reversible soft-remove flag (Close-is-not-Destroy,
|
|
1102
|
+
* [[HS-NBV9-EKLJ-TASK-archive-restore-and-the-close-is-not]]) — distinct
|
|
1103
|
+
* from `DELETE /api/workspaces/:id` unregister, which drops the row
|
|
1104
|
+
* entirely. An archived workspace stays in Neon/registry.json but is
|
|
1105
|
+
* excluded from the default active list.
|
|
1106
|
+
*/
|
|
1107
|
+
archived: boolean("archived").notNull().default(false)
|
|
1108
|
+
});
|
|
1109
|
+
vaults = pgTable("vaults", {
|
|
1110
|
+
id: text("id").primaryKey(),
|
|
1111
|
+
workspaceId: text("workspace_id").notNull().references(() => workspaces.id, { onDelete: "cascade" }),
|
|
1112
|
+
accountId: text("account_id").notNull(),
|
|
1113
|
+
displayName: text("display_name").notNull(),
|
|
1114
|
+
repoRootAbs: text("repo_root_abs"),
|
|
1115
|
+
githubRemote: text("github_remote"),
|
|
1116
|
+
schemaVersion: integer("schema_version").notNull().default(1),
|
|
1117
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1118
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1119
|
+
/** Vault equivalent of `workspaces.archived` — see that column's doc comment. */
|
|
1120
|
+
archived: boolean("archived").notNull().default(false)
|
|
1121
|
+
});
|
|
1122
|
+
accountSettings = pgTable("account_settings", {
|
|
1123
|
+
accountId: text("account_id").primaryKey(),
|
|
1124
|
+
activeWorkspaceId: text("active_workspace_id").references(() => workspaces.id, {
|
|
1125
|
+
onDelete: "set null"
|
|
1126
|
+
}),
|
|
1127
|
+
activeVaultId: text("active_vault_id").references(() => vaults.id, { onDelete: "set null" }),
|
|
1128
|
+
// Admin dev/prod mode control (epic HS-Q6M2): the CLOUD rung of the dev-mode
|
|
1129
|
+
// resolution order (env → ~/.heyspex/dev-mode.json → THIS → default prod).
|
|
1130
|
+
// Shape: { mode: "prod"|"dev", reliance: "local"|"cloud", updated_at, source }.
|
|
1131
|
+
// Readers/writers MUST tolerate the column being absent (migration 0050 may
|
|
1132
|
+
// not have run yet on a given database) — catch 42703 and treat as unset.
|
|
1133
|
+
devMode: jsonb("dev_mode"),
|
|
1134
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1135
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
1136
|
+
});
|
|
1137
|
+
vaultGrants = pgTable(
|
|
1138
|
+
"vault_grants",
|
|
1139
|
+
{
|
|
1140
|
+
vaultId: text("vault_id").notNull().references(() => vaults.id, { onDelete: "cascade" }),
|
|
1141
|
+
accountId: text("account_id").notNull(),
|
|
1142
|
+
access: text("access").notNull().default("read"),
|
|
1143
|
+
grantedBy: text("granted_by").notNull(),
|
|
1144
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
1145
|
+
},
|
|
1146
|
+
(table) => [primaryKey({ columns: [table.vaultId, table.accountId] })]
|
|
1147
|
+
);
|
|
1148
|
+
githubInstallations = pgTable(
|
|
1149
|
+
"github_installations",
|
|
1150
|
+
{
|
|
1151
|
+
accountId: text("account_id").notNull(),
|
|
1152
|
+
githubAccountLogin: text("github_account_login").notNull(),
|
|
1153
|
+
installationId: integer("installation_id").notNull(),
|
|
1154
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1155
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
1156
|
+
},
|
|
1157
|
+
(table) => [primaryKey({ columns: [table.accountId, table.githubAccountLogin] })]
|
|
1158
|
+
);
|
|
1159
|
+
fleetMachines = pgTable(
|
|
1160
|
+
"fleet_machines",
|
|
1161
|
+
{
|
|
1162
|
+
machineKey: text("machine_key").primaryKey(),
|
|
1163
|
+
label: text("label"),
|
|
1164
|
+
gpu: text("gpu"),
|
|
1165
|
+
vramMb: integer("vram_mb"),
|
|
1166
|
+
ramMb: integer("ram_mb"),
|
|
1167
|
+
architecture: text("architecture"),
|
|
1168
|
+
// cuda | rocm | cpu
|
|
1169
|
+
os: text("os"),
|
|
1170
|
+
companionVersion: text("companion_version"),
|
|
1171
|
+
/**
|
|
1172
|
+
* Owning account, stamped from the redeemed join code's issuer at
|
|
1173
|
+
* `POST /api/fleet/join` ([[HS-DOD0-DIAG-fleet-tables-have-no-account-linkage]]).
|
|
1174
|
+
* Nullable: bearer-only `POST /api/fleet/register` registrations and
|
|
1175
|
+
* pre-migration machines have no attributable account ("unattributed" is the
|
|
1176
|
+
* honest state, not a fabricated owner default). The upsert COALESCEs so a
|
|
1177
|
+
* bearer re-register never overwrites a set account_id with null. Prerequisite
|
|
1178
|
+
* for per-account fleet isolation ([[HS-TNTI-EPIC-saas-multi-tenant-isolation]]).
|
|
1179
|
+
*/
|
|
1180
|
+
accountId: text("account_id"),
|
|
1181
|
+
/**
|
|
1182
|
+
* Task kinds this machine is willing to run ([[HS-60Z1-EPIC]]). NULL = any kind
|
|
1183
|
+
* (the pre-existing behavior, preserved for machines registered before this
|
|
1184
|
+
* column existed). Eligibility used to be model-only, so a build box could claim
|
|
1185
|
+
* a `research` task it had no way to execute — `claimNextTask` now also filters
|
|
1186
|
+
* on this list when it is set.
|
|
1187
|
+
*/
|
|
1188
|
+
eligibleKinds: text("eligible_kinds").array(),
|
|
1189
|
+
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
|
|
1190
|
+
registeredAt: timestamp("registered_at", { withTimezone: true }).default(sql`now()`)
|
|
1191
|
+
},
|
|
1192
|
+
(table) => [index("fleet_machines_account_idx").on(table.accountId)]
|
|
1193
|
+
);
|
|
1194
|
+
fleetTasks = pgTable(
|
|
1195
|
+
"fleet_tasks",
|
|
1196
|
+
{
|
|
1197
|
+
taskId: text("task_id").primaryKey(),
|
|
1198
|
+
kind: text("kind").notNull(),
|
|
1199
|
+
// build | breakdown | research | ...
|
|
1200
|
+
modelId: text("model_id"),
|
|
1201
|
+
spec: jsonb("spec").notNull(),
|
|
1202
|
+
target: text("target"),
|
|
1203
|
+
budgetMaxSeconds: integer("budget_max_seconds"),
|
|
1204
|
+
status: text("status").notNull().default("queued"),
|
|
1205
|
+
// queued|claimed|running|done|failed|cancelled
|
|
1206
|
+
claimedBy: text("claimed_by").references(() => fleetMachines.machineKey),
|
|
1207
|
+
claimedAt: timestamp("claimed_at", { withTimezone: true }),
|
|
1208
|
+
lastProgressAt: timestamp("last_progress_at", { withTimezone: true }),
|
|
1209
|
+
/**
|
|
1210
|
+
* The fleet-queue lane ([[HS-60Z1-EPIC]]) — a free-form name for a gated resource
|
|
1211
|
+
* whose work waits its turn (`research-firecrawl`, `build-gpu0`, …). Deliberately
|
|
1212
|
+
* NOT folded into `kind`: `kind` is an existing taxonomy (build|breakdown|research)
|
|
1213
|
+
* that the companion's model-eligibility matching already consumes, and overloading
|
|
1214
|
+
* it would change what shipped callers claim. NULL = a plain dispatch task with no
|
|
1215
|
+
* lane, i.e. every task written before this column existed.
|
|
1216
|
+
*/
|
|
1217
|
+
lane: text("lane"),
|
|
1218
|
+
/** Who asked for this work (agent id / session). First-class so the queue can be filtered by requester. */
|
|
1219
|
+
requestedBy: text("requested_by"),
|
|
1220
|
+
/**
|
|
1221
|
+
* Stale-claim provenance. The stall sweep resets `claimed_by` to NULL so a healthy
|
|
1222
|
+
* machine can re-claim; without these two columns the identity of the machine that
|
|
1223
|
+
* dropped the task is erased, and "who had it before it went stale" is unanswerable.
|
|
1224
|
+
* The sweep copies claimed_by/claimed_at here before nulling them, so a reclaim is
|
|
1225
|
+
* visible (`reclaimed: true`) rather than silent.
|
|
1226
|
+
*/
|
|
1227
|
+
lastClaimant: text("last_claimant"),
|
|
1228
|
+
lastClaimedAt: timestamp("last_claimed_at", { withTimezone: true }),
|
|
1229
|
+
createdAt: timestamp("created_at", { withTimezone: true }).default(sql`now()`)
|
|
1230
|
+
},
|
|
1231
|
+
(table) => [
|
|
1232
|
+
index("fleet_tasks_status_created_idx").on(table.status, table.createdAt),
|
|
1233
|
+
// Lane-scoped peek / rank / list — the fleet-queue read path ([[HS-60Z1-EPIC]]).
|
|
1234
|
+
index("fleet_tasks_lane_status_created_idx").on(table.lane, table.status, table.createdAt)
|
|
1235
|
+
]
|
|
1236
|
+
);
|
|
1237
|
+
fleetResults = pgTable(
|
|
1238
|
+
"fleet_results",
|
|
1239
|
+
{
|
|
1240
|
+
taskId: text("task_id").references(() => fleetTasks.taskId),
|
|
1241
|
+
idempotencyKey: text("idempotency_key").notNull(),
|
|
1242
|
+
machineKey: text("machine_key").references(() => fleetMachines.machineKey),
|
|
1243
|
+
status: text("status").notNull(),
|
|
1244
|
+
// done | failed
|
|
1245
|
+
reason: text("reason"),
|
|
1246
|
+
result: jsonb("result"),
|
|
1247
|
+
artifacts: jsonb("artifacts"),
|
|
1248
|
+
commitSha: text("commit_sha"),
|
|
1249
|
+
reportedAt: timestamp("reported_at", { withTimezone: true }).default(sql`now()`)
|
|
1250
|
+
},
|
|
1251
|
+
(table) => [primaryKey({ columns: [table.taskId, table.idempotencyKey] })]
|
|
1252
|
+
);
|
|
1253
|
+
userApiKeys = pgTable(
|
|
1254
|
+
"user_api_keys",
|
|
1255
|
+
{
|
|
1256
|
+
userId: text("user_id").notNull().default(SINGLE_USER_ID),
|
|
1257
|
+
/** Provider id — validated app-side against `API_KEY_PROVIDERS`. */
|
|
1258
|
+
provider: text("provider").notNull(),
|
|
1259
|
+
// Envelope-encryption fields (all base64). See lib/keys/crypto.ts.
|
|
1260
|
+
ciphertext: text("ciphertext").notNull(),
|
|
1261
|
+
iv: text("iv").notNull(),
|
|
1262
|
+
authTag: text("auth_tag").notNull(),
|
|
1263
|
+
wrappedDek: text("wrapped_dek").notNull(),
|
|
1264
|
+
wrapIv: text("wrap_iv").notNull(),
|
|
1265
|
+
wrapAuthTag: text("wrap_auth_tag").notNull(),
|
|
1266
|
+
/** Which KEK version wrapped the data key (rotation selector). */
|
|
1267
|
+
keyVersion: integer("key_version").notNull().default(1),
|
|
1268
|
+
/** Trailing 4 chars of the plaintext — masked-display projection only. */
|
|
1269
|
+
last4: text("last4").notNull(),
|
|
1270
|
+
/** Best-effort verify-on-save result: unverified | valid | invalid. */
|
|
1271
|
+
verificationStatus: text("verification_status").notNull().default("unverified"),
|
|
1272
|
+
verifiedAt: timestamp("verified_at", { withTimezone: true }),
|
|
1273
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1274
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
1275
|
+
},
|
|
1276
|
+
(table) => [primaryKey({ columns: [table.userId, table.provider] })]
|
|
1277
|
+
);
|
|
1278
|
+
apiKeys = pgTable(
|
|
1279
|
+
"api_keys",
|
|
1280
|
+
{
|
|
1281
|
+
id: text("id").primaryKey(),
|
|
1282
|
+
// opaque key id (uuid), minted at creation — never the raw key
|
|
1283
|
+
keyHash: text("key_hash").notNull(),
|
|
1284
|
+
// SHA-256 hex digest of the raw key; the raw key itself is never stored
|
|
1285
|
+
keyPrefix: text("key_prefix").notNull(),
|
|
1286
|
+
// short non-secret prefix for masked display + lookup
|
|
1287
|
+
scopes: text("scopes").array().notNull(),
|
|
1288
|
+
accountId: text("account_id").notNull().default(SINGLE_USER_ID),
|
|
1289
|
+
status: text("status").notNull().default("active"),
|
|
1290
|
+
// active | expiring | revoked
|
|
1291
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1292
|
+
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
|
1293
|
+
expiresAt: timestamp("expires_at", { withTimezone: true })
|
|
1294
|
+
},
|
|
1295
|
+
(table) => [
|
|
1296
|
+
// Presented-key lookup path: verifyApiKey resolves by prefix, then
|
|
1297
|
+
// compares the hash. One key can never collide with another's prefix.
|
|
1298
|
+
uniqueIndex("api_keys_prefix_uq").on(table.keyPrefix),
|
|
1299
|
+
// Per-account listing + the max-active-keys-per-account enforcement scan.
|
|
1300
|
+
index("api_keys_account_status_idx").on(table.accountId, table.status)
|
|
1301
|
+
]
|
|
1302
|
+
);
|
|
1303
|
+
mcpSessions = pgTable(
|
|
1304
|
+
"mcp_sessions",
|
|
1305
|
+
{
|
|
1306
|
+
id: text("id").primaryKey(),
|
|
1307
|
+
// first 12 hex chars of the token hash — list/revoke/audit reference
|
|
1308
|
+
tokenHash: text("token_hash").notNull(),
|
|
1309
|
+
// SHA-256 hex digest of the raw `hsmcp_...` token
|
|
1310
|
+
userId: text("user_id").notNull(),
|
|
1311
|
+
// Clerk userId (or SINGLE_USER_ID) the token authenticates as
|
|
1312
|
+
label: text("label"),
|
|
1313
|
+
// optional human label ("laptop MCP", "CI runner", …)
|
|
1314
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1315
|
+
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
|
1316
|
+
revokedAt: timestamp("revoked_at", { withTimezone: true })
|
|
1317
|
+
},
|
|
1318
|
+
(table) => [
|
|
1319
|
+
// Presented-token lookup: verify hashes the FULL presented token and scans
|
|
1320
|
+
// for a match — this index is for per-account listing, not the verify path
|
|
1321
|
+
// (the verify path has no indexable prefix to look up by; the sessions
|
|
1322
|
+
// table is expected to stay small — one row per paired machine/agent).
|
|
1323
|
+
index("mcp_sessions_user_idx").on(table.userId)
|
|
1324
|
+
]
|
|
1325
|
+
);
|
|
1326
|
+
pairingCodes = pgTable(
|
|
1327
|
+
"pairing_codes",
|
|
1328
|
+
{
|
|
1329
|
+
id: text("id").primaryKey(),
|
|
1330
|
+
// first 8 hex chars of the code hash — audit/revoke reference
|
|
1331
|
+
hash: text("hash").notNull(),
|
|
1332
|
+
// SHA-256 hex digest of the plaintext code
|
|
1333
|
+
userId: text("user_id").notNull(),
|
|
1334
|
+
// Clerk userId (or SINGLE_USER_ID) that minted the code
|
|
1335
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1336
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
1337
|
+
usedAt: timestamp("used_at", { withTimezone: true }),
|
|
1338
|
+
usedBy: text("used_by"),
|
|
1339
|
+
// machineKey that redeemed the code, if supplied
|
|
1340
|
+
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
|
1341
|
+
note: text("note")
|
|
1342
|
+
// display/audit hint only ("mcp", …) — no effect on redemption
|
|
1343
|
+
},
|
|
1344
|
+
(table) => [index("pairing_codes_user_idx").on(table.userId)]
|
|
1345
|
+
);
|
|
1346
|
+
companionSessions = pgTable(
|
|
1347
|
+
"companion_sessions",
|
|
1348
|
+
{
|
|
1349
|
+
id: text("id").primaryKey(),
|
|
1350
|
+
// first 12 hex chars of the token hash — list/revoke/audit reference
|
|
1351
|
+
tokenHash: text("token_hash").notNull(),
|
|
1352
|
+
// SHA-256 hex digest of the raw `hscmp_...` token
|
|
1353
|
+
userId: text("user_id").notNull(),
|
|
1354
|
+
machineKey: text("machine_key"),
|
|
1355
|
+
// machineKey presented at pairing, if any
|
|
1356
|
+
label: text("label"),
|
|
1357
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1358
|
+
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
|
|
1359
|
+
revokedAt: timestamp("revoked_at", { withTimezone: true })
|
|
1360
|
+
},
|
|
1361
|
+
(table) => [index("companion_sessions_user_idx").on(table.userId)]
|
|
1362
|
+
);
|
|
1363
|
+
inviteRequests = pgTable(
|
|
1364
|
+
"invite_requests",
|
|
1365
|
+
{
|
|
1366
|
+
id: text("id").primaryKey(),
|
|
1367
|
+
// uuid minted by the webhook receiver
|
|
1368
|
+
email: text("email").notNull(),
|
|
1369
|
+
name: text("name"),
|
|
1370
|
+
/** Plan the requester expects to need: free | pro | team. A planning signal, not a commitment. */
|
|
1371
|
+
tier: text("tier").notNull().default("free"),
|
|
1372
|
+
/** Optional free-text context: what they are building / how they use agents. */
|
|
1373
|
+
building: text("building"),
|
|
1374
|
+
status: text("status").notNull().default("new"),
|
|
1375
|
+
// new | invited | declined
|
|
1376
|
+
/** Clerk invitation id, set when an approval actually issues one. */
|
|
1377
|
+
clerkInvitationId: text("clerk_invitation_id"),
|
|
1378
|
+
/** Account id of the admin who approved/declined — audit trail for the decision. */
|
|
1379
|
+
reviewedBy: text("reviewed_by"),
|
|
1380
|
+
reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
|
|
1381
|
+
/** Intake metadata forwarded from the Worker, for spam triage. */
|
|
1382
|
+
sourceIp: text("source_ip"),
|
|
1383
|
+
userAgent: text("user_agent"),
|
|
1384
|
+
/**
|
|
1385
|
+
* Double opt-in completion. Rows only reach this table AFTER the requester
|
|
1386
|
+
* clicked the confirmation link, so in practice this is always set — it is
|
|
1387
|
+
* stored anyway because "when did they verify?" is part of the consent
|
|
1388
|
+
* evidence, and a future intake path might relax that ordering.
|
|
1389
|
+
*/
|
|
1390
|
+
confirmedAt: timestamp("confirmed_at", { withTimezone: true }),
|
|
1391
|
+
/**
|
|
1392
|
+
* The OPTIONAL marketing opt-in — deliberately distinct from the required
|
|
1393
|
+
* privacy agreement, which is a condition of using the form and therefore
|
|
1394
|
+
* not evidence of marketing consent. Only this flag may be used to justify
|
|
1395
|
+
* sending marketing email.
|
|
1396
|
+
*/
|
|
1397
|
+
marketingConsent: boolean("marketing_consent").notNull().default(false),
|
|
1398
|
+
/**
|
|
1399
|
+
* Version tag of the exact on-page wording the requester agreed to
|
|
1400
|
+
* (`CONSENT_TEXT_VERSION` in heyspex-com/src/lib/consent-text.ts). Without
|
|
1401
|
+
* this, editing the copy would silently rewrite what every historical row
|
|
1402
|
+
* appears to have consented to.
|
|
1403
|
+
*/
|
|
1404
|
+
consentTextVersion: text("consent_text_version"),
|
|
1405
|
+
/** When the marketing site recorded it (NOT when the webhook landed). */
|
|
1406
|
+
requestedAt: timestamp("requested_at", { withTimezone: true }).notNull().default(sql`now()`),
|
|
1407
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
1408
|
+
},
|
|
1409
|
+
(table) => [
|
|
1410
|
+
// The upsert key — one row per requester, re-submits update in place.
|
|
1411
|
+
uniqueIndex("invite_requests_email_uq").on(table.email),
|
|
1412
|
+
// The admin surface's default read: pending first, newest first.
|
|
1413
|
+
index("invite_requests_status_requested_idx").on(table.status, table.requestedAt)
|
|
1414
|
+
]
|
|
1415
|
+
);
|
|
1416
|
+
notifications = pgTable(
|
|
1417
|
+
"notifications",
|
|
1418
|
+
{
|
|
1419
|
+
id: text("id").primaryKey(),
|
|
1420
|
+
// uuid
|
|
1421
|
+
/** Recipient. Every read path filters on this — notifications are never global. */
|
|
1422
|
+
accountId: text("account_id").notNull().default(SINGLE_USER_ID),
|
|
1423
|
+
/** Event type, e.g. `invite_request`. Open vocabulary by design (see above). */
|
|
1424
|
+
kind: text("kind").notNull(),
|
|
1425
|
+
title: text("title").notNull(),
|
|
1426
|
+
/** One-line supporting detail shown under the title in the dropdown. */
|
|
1427
|
+
body: text("body"),
|
|
1428
|
+
/** Where clicking the notification should take the user (in-app path). */
|
|
1429
|
+
href: text("href"),
|
|
1430
|
+
/**
|
|
1431
|
+
* Kind-specific structured data (e.g. `{ inviteRequestId, email, tier }`).
|
|
1432
|
+
* Keeps the table stable as new kinds arrive.
|
|
1433
|
+
*/
|
|
1434
|
+
payload: jsonb("payload"),
|
|
1435
|
+
/**
|
|
1436
|
+
* Idempotency key for the producing event (e.g. `invite_request:<email>`).
|
|
1437
|
+
* A webhook retry must surface ONE bell item, not one per delivery attempt.
|
|
1438
|
+
*/
|
|
1439
|
+
dedupeKey: text("dedupe_key"),
|
|
1440
|
+
readAt: timestamp("read_at", { withTimezone: true }),
|
|
1441
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
1442
|
+
},
|
|
1443
|
+
(table) => [
|
|
1444
|
+
// The bell's two queries: unread count, and the recent list — both scoped
|
|
1445
|
+
// to the recipient and ordered newest-first.
|
|
1446
|
+
index("notifications_account_created_idx").on(table.accountId, table.createdAt),
|
|
1447
|
+
// Retry-safe producers: one row per (recipient, event).
|
|
1448
|
+
uniqueIndex("notifications_account_dedupe_uq").on(table.accountId, table.dedupeKey)
|
|
1449
|
+
]
|
|
1450
|
+
);
|
|
1451
|
+
webhookIngestions = pgTable(
|
|
1452
|
+
"webhook_ingestions",
|
|
1453
|
+
{
|
|
1454
|
+
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
|
1455
|
+
repo: text("repo").notNull(),
|
|
1456
|
+
// `owner/repo`, from `repository.full_name`
|
|
1457
|
+
branch: text("branch").notNull(),
|
|
1458
|
+
// from `ref`, `refs/heads/` stripped
|
|
1459
|
+
commitSha: text("commit_sha").notNull(),
|
|
1460
|
+
message: text("message").notNull(),
|
|
1461
|
+
authorName: text("author_name").notNull(),
|
|
1462
|
+
authorEmail: text("author_email").notNull(),
|
|
1463
|
+
path: text("path").notNull(),
|
|
1464
|
+
// changed file's repo-relative path
|
|
1465
|
+
ingestedAt: timestamp("ingested_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
1466
|
+
},
|
|
1467
|
+
(table) => [
|
|
1468
|
+
// A retried/duplicate delivery for the same (repo, commit, path) must not
|
|
1469
|
+
// record twice — GitHub does redeliver on timeout/5xx.
|
|
1470
|
+
uniqueIndex("webhook_ingestions_repo_commit_path_uq").on(
|
|
1471
|
+
table.repo,
|
|
1472
|
+
table.commitSha,
|
|
1473
|
+
table.path
|
|
1474
|
+
)
|
|
1475
|
+
]
|
|
1476
|
+
);
|
|
1477
|
+
specSettlements = pgTable(
|
|
1478
|
+
"spec_settlements",
|
|
1479
|
+
{
|
|
1480
|
+
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
|
1481
|
+
specId: text("spec_id").notNull(),
|
|
1482
|
+
vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
|
|
1483
|
+
repo: text("repo").notNull(),
|
|
1484
|
+
// `owner/name`
|
|
1485
|
+
branch: text("branch").notNull(),
|
|
1486
|
+
commitSha: text("commit_sha").notNull(),
|
|
1487
|
+
settledAt: timestamp("settled_at", { withTimezone: true }).notNull().default(sql`now()`)
|
|
1488
|
+
},
|
|
1489
|
+
(table) => [
|
|
1490
|
+
// "latest settlement for this spec" — the read path's only query.
|
|
1491
|
+
index("spec_settlements_spec_settled_idx").on(table.specId, table.settledAt.desc())
|
|
1492
|
+
]
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
// spex-console/src/lib/vault-registry/neon-probe.ts
|
|
1498
|
+
function positiveIntFromEnv(name) {
|
|
1499
|
+
const raw = process.env[name]?.trim();
|
|
1500
|
+
if (!raw) return null;
|
|
1501
|
+
const value = Number.parseInt(raw, 10);
|
|
1502
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
1503
|
+
}
|
|
1504
|
+
function bootNeonProbeOptions(onFailure) {
|
|
1505
|
+
return {
|
|
1506
|
+
timeoutMs: positiveIntFromEnv("HEYSPEX_NEON_BOOT_PROBE_TIMEOUT_MS") ?? NEON_BOOT_PROBE_TIMEOUT_MS,
|
|
1507
|
+
backoffMs: NEON_BOOT_PROBE_BACKOFF_MS,
|
|
1508
|
+
deadlineMs: positiveIntFromEnv("HEYSPEX_NEON_BOOT_PROBE_DEADLINE_MS") ?? NEON_BOOT_PROBE_DEADLINE_MS,
|
|
1509
|
+
onFailure
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
function withTimeout(promise, ms, label) {
|
|
1513
|
+
return new Promise((resolve, reject) => {
|
|
1514
|
+
const timer = setTimeout(() => reject(new NeonProbeTimeoutError(label, ms)), ms);
|
|
1515
|
+
promise.then(
|
|
1516
|
+
(value) => {
|
|
1517
|
+
clearTimeout(timer);
|
|
1518
|
+
resolve(value);
|
|
1519
|
+
},
|
|
1520
|
+
(err) => {
|
|
1521
|
+
clearTimeout(timer);
|
|
1522
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
1523
|
+
}
|
|
1524
|
+
);
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
function sleep(ms) {
|
|
1528
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1529
|
+
}
|
|
1530
|
+
function emitProbeFailure(options, failure) {
|
|
1531
|
+
try {
|
|
1532
|
+
options?.onFailure?.(failure);
|
|
1533
|
+
} catch {
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
async function runNeonReachabilityProbe(attempt, options = {}, label = "Neon reachability probe") {
|
|
1537
|
+
const timeoutMs = options.timeoutMs ?? NEON_PROBE_TIMEOUT_MS;
|
|
1538
|
+
const backoffMs = options.backoffMs ?? [];
|
|
1539
|
+
const deadlineMs = options.deadlineMs ?? Number.POSITIVE_INFINITY;
|
|
1540
|
+
const attempts = backoffMs.length + 1;
|
|
1541
|
+
let startedAt = Date.now();
|
|
1542
|
+
let suspensions = 0;
|
|
1543
|
+
for (let i = 0; i < attempts; i++) {
|
|
1544
|
+
const attemptStartedAt = Date.now();
|
|
1545
|
+
try {
|
|
1546
|
+
await withTimeout(Promise.resolve().then(attempt), timeoutMs, label);
|
|
1547
|
+
return true;
|
|
1548
|
+
} catch (err) {
|
|
1549
|
+
const elapsedMs = Date.now() - attemptStartedAt;
|
|
1550
|
+
const suspended = elapsedMs >= timeoutMs * SUSPENSION_ELAPSED_FACTOR && elapsedMs - timeoutMs >= SUSPENSION_MIN_OVERSHOOT_MS;
|
|
1551
|
+
if (suspended && suspensions < MAX_SUSPENSION_RETRIES) {
|
|
1552
|
+
suspensions += 1;
|
|
1553
|
+
emitProbeFailure(options, {
|
|
1554
|
+
reason: "suspended",
|
|
1555
|
+
attempt: i + 1,
|
|
1556
|
+
attempts,
|
|
1557
|
+
timeoutMs,
|
|
1558
|
+
elapsedMs,
|
|
1559
|
+
willRetry: true,
|
|
1560
|
+
error: err
|
|
1561
|
+
});
|
|
1562
|
+
startedAt = Date.now();
|
|
1563
|
+
i -= 1;
|
|
1564
|
+
continue;
|
|
1565
|
+
}
|
|
1566
|
+
const isLast = i === attempts - 1;
|
|
1567
|
+
const pastDeadline = Date.now() - startedAt >= deadlineMs;
|
|
1568
|
+
const willRetry = !isLast && !pastDeadline;
|
|
1569
|
+
emitProbeFailure(options, {
|
|
1570
|
+
reason: err instanceof NeonProbeTimeoutError ? "timeout" : "error",
|
|
1571
|
+
attempt: i + 1,
|
|
1572
|
+
attempts,
|
|
1573
|
+
timeoutMs,
|
|
1574
|
+
elapsedMs,
|
|
1575
|
+
willRetry,
|
|
1576
|
+
error: err
|
|
1577
|
+
});
|
|
1578
|
+
if (!willRetry) return false;
|
|
1579
|
+
await sleep(backoffMs[i]);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
return false;
|
|
1583
|
+
}
|
|
1584
|
+
var NEON_PROBE_TIMEOUT_MS, NEON_BOOT_PROBE_TIMEOUT_MS, NEON_BOOT_PROBE_BACKOFF_MS, NEON_BOOT_PROBE_DEADLINE_MS, SUSPENSION_ELAPSED_FACTOR, SUSPENSION_MIN_OVERSHOOT_MS, MAX_SUSPENSION_RETRIES, NeonProbeTimeoutError;
|
|
1585
|
+
var init_neon_probe = __esm({
|
|
1586
|
+
"spex-console/src/lib/vault-registry/neon-probe.ts"() {
|
|
1587
|
+
"use strict";
|
|
1588
|
+
NEON_PROBE_TIMEOUT_MS = 1500;
|
|
1589
|
+
NEON_BOOT_PROBE_TIMEOUT_MS = 12e3;
|
|
1590
|
+
NEON_BOOT_PROBE_BACKOFF_MS = [1e3, 3e3];
|
|
1591
|
+
NEON_BOOT_PROBE_DEADLINE_MS = 2e4;
|
|
1592
|
+
SUSPENSION_ELAPSED_FACTOR = 3;
|
|
1593
|
+
SUSPENSION_MIN_OVERSHOOT_MS = 5e3;
|
|
1594
|
+
MAX_SUSPENSION_RETRIES = 2;
|
|
1595
|
+
NeonProbeTimeoutError = class _NeonProbeTimeoutError extends Error {
|
|
1596
|
+
timeoutMs;
|
|
1597
|
+
constructor(label, timeoutMs) {
|
|
1598
|
+
super(`${label} timed out after ${timeoutMs}ms`);
|
|
1599
|
+
this.name = "NeonProbeTimeoutError";
|
|
1600
|
+
this.timeoutMs = timeoutMs;
|
|
1601
|
+
Object.setPrototypeOf(this, _NeonProbeTimeoutError.prototype);
|
|
1602
|
+
}
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1607
|
+
// spex-console/src/lib/vault-registry/types.ts
|
|
1608
|
+
function emptyVaultRegistry() {
|
|
1609
|
+
return {
|
|
1610
|
+
schema_version: VAULT_REGISTRY_SCHEMA_VERSION,
|
|
1611
|
+
workspaces: [],
|
|
1612
|
+
vaults: [],
|
|
1613
|
+
active_workspace_id: null,
|
|
1614
|
+
active_vault_id: null
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
var VAULT_REGISTRY_SCHEMA_VERSION;
|
|
1618
|
+
var init_types = __esm({
|
|
1619
|
+
"spex-console/src/lib/vault-registry/types.ts"() {
|
|
1620
|
+
"use strict";
|
|
1621
|
+
VAULT_REGISTRY_SCHEMA_VERSION = 1;
|
|
1622
|
+
}
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
// spex-console/src/lib/vault-registry/registry-file.ts
|
|
1626
|
+
import { existsSync, mkdirSync, readFileSync as readFileSync4, renameSync, writeFileSync } from "node:fs";
|
|
1627
|
+
import { homedir as homedir3 } from "node:os";
|
|
1628
|
+
import { dirname, join as join3 } from "node:path";
|
|
1629
|
+
function vaultRegistryDir(opts = {}) {
|
|
1630
|
+
return opts.registryDir ?? join3(homedir3(), ".heyspex");
|
|
1631
|
+
}
|
|
1632
|
+
function vaultRegistryFilePath(opts = {}) {
|
|
1633
|
+
return join3(vaultRegistryDir(opts), "registry.json");
|
|
1634
|
+
}
|
|
1635
|
+
function isNonEmptyString(v) {
|
|
1636
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
1637
|
+
}
|
|
1638
|
+
function normalizeWorkspace(raw) {
|
|
1639
|
+
if (!raw || typeof raw !== "object") return null;
|
|
1640
|
+
const o = raw;
|
|
1641
|
+
if (!isNonEmptyString(o.id)) return null;
|
|
1642
|
+
if (!isNonEmptyString(o.display_name)) return null;
|
|
1643
|
+
if (!isNonEmptyString(o.created_at)) return null;
|
|
1644
|
+
const record = {
|
|
1645
|
+
id: o.id,
|
|
1646
|
+
display_name: o.display_name,
|
|
1647
|
+
created_at: o.created_at,
|
|
1648
|
+
archived: o.archived === true
|
|
1649
|
+
};
|
|
1650
|
+
if (isNonEmptyString(o.github_org)) record.github_org = o.github_org;
|
|
1651
|
+
return record;
|
|
1652
|
+
}
|
|
1653
|
+
function normalizeVault(raw) {
|
|
1654
|
+
if (!raw || typeof raw !== "object") return null;
|
|
1655
|
+
const o = raw;
|
|
1656
|
+
if (!isNonEmptyString(o.id)) return null;
|
|
1657
|
+
if (!isNonEmptyString(o.workspace_id)) return null;
|
|
1658
|
+
if (!isNonEmptyString(o.display_name)) return null;
|
|
1659
|
+
if (!isNonEmptyString(o.repo_root_abs)) return null;
|
|
1660
|
+
if (typeof o.schema_version !== "number") return null;
|
|
1661
|
+
if (!isNonEmptyString(o.registered_at)) return null;
|
|
1662
|
+
const record = {
|
|
1663
|
+
id: o.id,
|
|
1664
|
+
workspace_id: o.workspace_id,
|
|
1665
|
+
display_name: o.display_name,
|
|
1666
|
+
repo_root_abs: o.repo_root_abs,
|
|
1667
|
+
schema_version: o.schema_version,
|
|
1668
|
+
registered_at: o.registered_at,
|
|
1669
|
+
archived: o.archived === true
|
|
1670
|
+
};
|
|
1671
|
+
if (isNonEmptyString(o.github_remote)) record.github_remote = o.github_remote;
|
|
1672
|
+
return record;
|
|
1673
|
+
}
|
|
1674
|
+
function parseVaultRegistryFile(json) {
|
|
1675
|
+
const fallback = emptyVaultRegistry();
|
|
1676
|
+
if (!json || typeof json !== "object") return fallback;
|
|
1677
|
+
const o = json;
|
|
1678
|
+
const workspacesRaw = Array.isArray(o.workspaces) ? o.workspaces : [];
|
|
1679
|
+
const workspaces2 = workspacesRaw.map(normalizeWorkspace).filter((w) => w !== null);
|
|
1680
|
+
const vaultsRaw = Array.isArray(o.vaults) ? o.vaults : [];
|
|
1681
|
+
const vaults2 = vaultsRaw.map(normalizeVault).filter((v) => v !== null);
|
|
1682
|
+
const active_workspace_id = isNonEmptyString(o.active_workspace_id) ? o.active_workspace_id : null;
|
|
1683
|
+
const active_vault_id = isNonEmptyString(o.active_vault_id) ? o.active_vault_id : null;
|
|
1684
|
+
return {
|
|
1685
|
+
schema_version: VAULT_REGISTRY_SCHEMA_VERSION,
|
|
1686
|
+
workspaces: workspaces2,
|
|
1687
|
+
vaults: vaults2,
|
|
1688
|
+
active_workspace_id,
|
|
1689
|
+
active_vault_id
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
function loadVaultRegistry(opts = {}) {
|
|
1693
|
+
const filePath = vaultRegistryFilePath(opts);
|
|
1694
|
+
if (!existsSync(filePath)) return emptyVaultRegistry();
|
|
1695
|
+
try {
|
|
1696
|
+
const raw = readFileSync4(filePath, "utf8");
|
|
1697
|
+
return parseVaultRegistryFile(JSON.parse(raw));
|
|
1698
|
+
} catch {
|
|
1699
|
+
return emptyVaultRegistry();
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
function writeVaultRegistry(registry, opts = {}) {
|
|
1703
|
+
const filePath = vaultRegistryFilePath(opts);
|
|
1704
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
1705
|
+
const tmpPath = `${filePath}.tmp`;
|
|
1706
|
+
writeFileSync(tmpPath, `${JSON.stringify(registry, null, 2)}
|
|
1707
|
+
`, "utf8");
|
|
1708
|
+
renameSync(tmpPath, filePath);
|
|
1709
|
+
}
|
|
1710
|
+
function isValidId(id) {
|
|
1711
|
+
if (!isNonEmptyString(id)) return false;
|
|
1712
|
+
if (id.includes("/") || id.includes("\\")) return false;
|
|
1713
|
+
if (id === "." || id === "..") return false;
|
|
1714
|
+
return true;
|
|
1715
|
+
}
|
|
1716
|
+
function registerWorkspace(record, opts = {}) {
|
|
1717
|
+
if (!isValidId(record.id)) {
|
|
1718
|
+
throw new Error(`registerWorkspace: invalid workspace id "${record.id}"`);
|
|
1719
|
+
}
|
|
1720
|
+
const registry = loadVaultRegistry(opts);
|
|
1721
|
+
const idx = registry.workspaces.findIndex((w) => w.id === record.id);
|
|
1722
|
+
if (idx >= 0) {
|
|
1723
|
+
registry.workspaces[idx] = { ...record };
|
|
1724
|
+
} else {
|
|
1725
|
+
registry.workspaces.push({ ...record });
|
|
1726
|
+
}
|
|
1727
|
+
writeVaultRegistry(registry, opts);
|
|
1728
|
+
return registry;
|
|
1729
|
+
}
|
|
1730
|
+
function registerVault(record, opts = {}) {
|
|
1731
|
+
if (!isValidId(record.id)) {
|
|
1732
|
+
throw new Error(`registerVault: invalid vault id "${record.id}"`);
|
|
1733
|
+
}
|
|
1734
|
+
if (!isValidId(record.workspace_id)) {
|
|
1735
|
+
throw new Error(`registerVault: invalid workspace_id "${record.workspace_id}"`);
|
|
1736
|
+
}
|
|
1737
|
+
if (!isNonEmptyString(record.repo_root_abs)) {
|
|
1738
|
+
throw new Error("registerVault: repo_root_abs is required");
|
|
1739
|
+
}
|
|
1740
|
+
const registry = loadVaultRegistry(opts);
|
|
1741
|
+
const isSameVault = (v) => v.id === record.id || v.repo_root_abs === record.repo_root_abs;
|
|
1742
|
+
const idx = registry.vaults.findIndex(isSameVault);
|
|
1743
|
+
if (idx >= 0) {
|
|
1744
|
+
registry.vaults[idx] = { ...record };
|
|
1745
|
+
registry.vaults = registry.vaults.filter((v, i) => i === idx || !isSameVault(v));
|
|
1746
|
+
} else {
|
|
1747
|
+
registry.vaults.push({ ...record });
|
|
1748
|
+
}
|
|
1749
|
+
writeVaultRegistry(registry, opts);
|
|
1750
|
+
return registry;
|
|
1751
|
+
}
|
|
1752
|
+
function setActiveVaultId(vaultId, opts = {}) {
|
|
1753
|
+
const registry = loadVaultRegistry(opts);
|
|
1754
|
+
registry.active_vault_id = vaultId;
|
|
1755
|
+
writeVaultRegistry(registry, opts);
|
|
1756
|
+
return registry;
|
|
1757
|
+
}
|
|
1758
|
+
function setActiveWorkspaceId(workspaceId, opts = {}) {
|
|
1759
|
+
const registry = loadVaultRegistry(opts);
|
|
1760
|
+
registry.active_workspace_id = workspaceId;
|
|
1761
|
+
writeVaultRegistry(registry, opts);
|
|
1762
|
+
return registry;
|
|
1763
|
+
}
|
|
1764
|
+
function removeVault(id, opts = {}) {
|
|
1765
|
+
const registry = loadVaultRegistry(opts);
|
|
1766
|
+
registry.vaults = registry.vaults.filter((v) => v.id !== id);
|
|
1767
|
+
if (registry.active_vault_id === id) {
|
|
1768
|
+
registry.active_vault_id = null;
|
|
1769
|
+
}
|
|
1770
|
+
writeVaultRegistry(registry, opts);
|
|
1771
|
+
return registry;
|
|
1772
|
+
}
|
|
1773
|
+
var init_registry_file = __esm({
|
|
1774
|
+
"spex-console/src/lib/vault-registry/registry-file.ts"() {
|
|
1775
|
+
"use strict";
|
|
1776
|
+
init_types();
|
|
1777
|
+
init_types();
|
|
1778
|
+
}
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
// spex-console/src/lib/vault-registry/account-pointer.ts
|
|
1782
|
+
var account_pointer_exports = {};
|
|
1783
|
+
__export(account_pointer_exports, {
|
|
1784
|
+
UnknownVaultIdError: () => UnknownVaultIdError,
|
|
1785
|
+
UnknownWorkspaceIdError: () => UnknownWorkspaceIdError,
|
|
1786
|
+
clearActiveVault: () => clearActiveVault,
|
|
1787
|
+
clearActiveWorkspace: () => clearActiveWorkspace,
|
|
1788
|
+
getActiveVault: () => getActiveVault,
|
|
1789
|
+
getActiveWorkspace: () => getActiveWorkspace,
|
|
1790
|
+
hasLocalVault: () => hasLocalVault,
|
|
1791
|
+
isVaultOwnedByAccount: () => isVaultOwnedByAccount,
|
|
1792
|
+
setActiveVault: () => setActiveVault,
|
|
1793
|
+
setActiveWorkspace: () => setActiveWorkspace
|
|
1794
|
+
});
|
|
1795
|
+
async function isNeonReachable(deps) {
|
|
1796
|
+
if (deps.neonReachable !== void 0) return deps.neonReachable;
|
|
1797
|
+
const usingInjectedDb = deps.db !== void 0;
|
|
1798
|
+
if (!usingInjectedDb && !isPostgresConfigured()) {
|
|
1799
|
+
emitProbeFailure(deps.probe, {
|
|
1800
|
+
reason: "not-configured",
|
|
1801
|
+
attempt: 1,
|
|
1802
|
+
attempts: 1,
|
|
1803
|
+
timeoutMs: 0,
|
|
1804
|
+
elapsedMs: 0,
|
|
1805
|
+
willRetry: false,
|
|
1806
|
+
error: new Error(
|
|
1807
|
+
"isPostgresConfigured() is false \u2014 no DATABASE_URL env var and no usable ~/.heyspex/credentials.yaml. Neon was never contacted."
|
|
1808
|
+
)
|
|
1809
|
+
});
|
|
1810
|
+
return false;
|
|
1811
|
+
}
|
|
1812
|
+
return runNeonReachabilityProbe(async () => {
|
|
1813
|
+
const db = deps.db ?? getPostgresDb();
|
|
1814
|
+
await db.execute(sql`select 1`);
|
|
1815
|
+
}, deps.probe);
|
|
1816
|
+
}
|
|
1817
|
+
async function accountSettingsRow(accountId2, db) {
|
|
1818
|
+
const rows = await db.select().from(accountSettings).where(eq(accountSettings.accountId, accountId2)).limit(1);
|
|
1819
|
+
return rows[0] ?? null;
|
|
1820
|
+
}
|
|
1821
|
+
async function upsertAccountSettings(accountId2, patch, db) {
|
|
1822
|
+
const now = /* @__PURE__ */ new Date();
|
|
1823
|
+
await db.insert(accountSettings).values({ accountId: accountId2, ...patch, createdAt: now, updatedAt: now }).onConflictDoUpdate({
|
|
1824
|
+
target: accountSettings.accountId,
|
|
1825
|
+
set: { ...patch, updatedAt: now }
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
async function vaultExistsInNeon(accountId2, vaultId, db) {
|
|
1829
|
+
const rows = await db.select({ id: vaults.id }).from(vaults).where(and(eq(vaults.id, vaultId), eq(vaults.accountId, accountId2))).limit(1);
|
|
1830
|
+
return rows.length > 0;
|
|
1831
|
+
}
|
|
1832
|
+
async function workspaceExistsInNeon(accountId2, workspaceId, db) {
|
|
1833
|
+
const rows = await db.select({ id: workspaces.id }).from(workspaces).where(and(eq(workspaces.id, workspaceId), eq(workspaces.accountId, accountId2))).limit(1);
|
|
1834
|
+
return rows.length > 0;
|
|
1835
|
+
}
|
|
1836
|
+
async function vaultGrantedToAccount(accountId2, vaultId, db) {
|
|
1837
|
+
const rows = await db.select({ vaultId: vaultGrants.vaultId }).from(vaultGrants).where(and(eq(vaultGrants.vaultId, vaultId), eq(vaultGrants.accountId, accountId2))).limit(1);
|
|
1838
|
+
return rows.length > 0;
|
|
1839
|
+
}
|
|
1840
|
+
async function vaultReachableByAccount(accountId2, vaultId, db) {
|
|
1841
|
+
if (await vaultExistsInNeon(accountId2, vaultId, db)) return true;
|
|
1842
|
+
return vaultGrantedToAccount(accountId2, vaultId, db);
|
|
1843
|
+
}
|
|
1844
|
+
function vaultExistsInRegistry(vaultId, opts) {
|
|
1845
|
+
return loadVaultRegistry(opts).vaults.some((v) => v.id === vaultId);
|
|
1846
|
+
}
|
|
1847
|
+
function workspaceExistsInRegistry(workspaceId, opts) {
|
|
1848
|
+
return loadVaultRegistry(opts).workspaces.some((w) => w.id === workspaceId);
|
|
1849
|
+
}
|
|
1850
|
+
async function isVaultOwnedByAccount(accountId2, vaultId, deps = {}) {
|
|
1851
|
+
if (!await isNeonReachable(deps)) return false;
|
|
1852
|
+
const db = deps.db ?? getPostgresDb();
|
|
1853
|
+
return vaultExistsInNeon(accountId2, vaultId, db);
|
|
1854
|
+
}
|
|
1855
|
+
async function hasLocalVault(accountId2, deps = {}) {
|
|
1856
|
+
try {
|
|
1857
|
+
if (!await isNeonReachable(deps)) return false;
|
|
1858
|
+
const db = deps.db ?? getPostgresDb();
|
|
1859
|
+
const rows = await db.select({ id: vaults.id }).from(vaults).where(
|
|
1860
|
+
and(
|
|
1861
|
+
eq(vaults.accountId, accountId2),
|
|
1862
|
+
isNotNull(vaults.repoRootAbs),
|
|
1863
|
+
eq(vaults.archived, false)
|
|
1864
|
+
)
|
|
1865
|
+
).limit(1);
|
|
1866
|
+
return rows.length > 0;
|
|
1867
|
+
} catch (err) {
|
|
1868
|
+
console.warn("[account-pointer] hasLocalVault lookup failed, treating as NOT connected:", err);
|
|
1869
|
+
return false;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
async function getActiveVault(accountId2, deps = {}) {
|
|
1873
|
+
if (await isNeonReachable(deps)) {
|
|
1874
|
+
const db = deps.db ?? getPostgresDb();
|
|
1875
|
+
const row = await accountSettingsRow(accountId2, db);
|
|
1876
|
+
if (row?.activeVaultId) return row.activeVaultId;
|
|
1877
|
+
const owned = await db.select({ id: vaults.id }).from(vaults).where(eq(vaults.accountId, accountId2)).limit(2);
|
|
1878
|
+
if (owned.length === 1) {
|
|
1879
|
+
await upsertAccountSettings(accountId2, { activeVaultId: owned[0].id }, db);
|
|
1880
|
+
return owned[0].id;
|
|
1881
|
+
}
|
|
1882
|
+
return null;
|
|
1883
|
+
}
|
|
1884
|
+
const registry = loadVaultRegistry(deps.registryOpts);
|
|
1885
|
+
if (registry.active_vault_id) return registry.active_vault_id;
|
|
1886
|
+
if (registry.vaults.length === 1) {
|
|
1887
|
+
setActiveVaultId(registry.vaults[0].id, deps.registryOpts ?? {});
|
|
1888
|
+
return registry.vaults[0].id;
|
|
1889
|
+
}
|
|
1890
|
+
return null;
|
|
1891
|
+
}
|
|
1892
|
+
async function getActiveWorkspace(accountId2, deps = {}) {
|
|
1893
|
+
if (await isNeonReachable(deps)) {
|
|
1894
|
+
const db = deps.db ?? getPostgresDb();
|
|
1895
|
+
const row = await accountSettingsRow(accountId2, db);
|
|
1896
|
+
return row?.activeWorkspaceId ?? null;
|
|
1897
|
+
}
|
|
1898
|
+
return loadVaultRegistry(deps.registryOpts).active_workspace_id;
|
|
1899
|
+
}
|
|
1900
|
+
async function setActiveVault(accountId2, vaultId, deps = {}) {
|
|
1901
|
+
if (await isNeonReachable(deps)) {
|
|
1902
|
+
const db = deps.db ?? getPostgresDb();
|
|
1903
|
+
if (!await vaultReachableByAccount(accountId2, vaultId, db)) {
|
|
1904
|
+
throw new UnknownVaultIdError(vaultId);
|
|
1905
|
+
}
|
|
1906
|
+
await upsertAccountSettings(accountId2, { activeVaultId: vaultId }, db);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
const registryOpts = deps.registryOpts ?? {};
|
|
1910
|
+
if (!vaultExistsInRegistry(vaultId, registryOpts)) {
|
|
1911
|
+
throw new UnknownVaultIdError(vaultId);
|
|
1912
|
+
}
|
|
1913
|
+
setActiveVaultId(vaultId, registryOpts);
|
|
1914
|
+
}
|
|
1915
|
+
async function setActiveWorkspace(accountId2, workspaceId, deps = {}) {
|
|
1916
|
+
if (await isNeonReachable(deps)) {
|
|
1917
|
+
const db = deps.db ?? getPostgresDb();
|
|
1918
|
+
if (!await workspaceExistsInNeon(accountId2, workspaceId, db)) {
|
|
1919
|
+
throw new UnknownWorkspaceIdError(workspaceId);
|
|
1920
|
+
}
|
|
1921
|
+
await upsertAccountSettings(accountId2, { activeWorkspaceId: workspaceId }, db);
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
const registryOpts = deps.registryOpts ?? {};
|
|
1925
|
+
if (!workspaceExistsInRegistry(workspaceId, registryOpts)) {
|
|
1926
|
+
throw new UnknownWorkspaceIdError(workspaceId);
|
|
1927
|
+
}
|
|
1928
|
+
setActiveWorkspaceId(workspaceId, registryOpts);
|
|
1929
|
+
}
|
|
1930
|
+
async function clearActiveVault(accountId2, deps = {}) {
|
|
1931
|
+
if (await isNeonReachable(deps)) {
|
|
1932
|
+
const db = deps.db ?? getPostgresDb();
|
|
1933
|
+
await upsertAccountSettings(accountId2, { activeVaultId: null }, db);
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
setActiveVaultId(null, deps.registryOpts ?? {});
|
|
1937
|
+
}
|
|
1938
|
+
async function clearActiveWorkspace(accountId2, deps = {}) {
|
|
1939
|
+
if (await isNeonReachable(deps)) {
|
|
1940
|
+
const db = deps.db ?? getPostgresDb();
|
|
1941
|
+
await upsertAccountSettings(accountId2, { activeWorkspaceId: null }, db);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
setActiveWorkspaceId(null, deps.registryOpts ?? {});
|
|
1945
|
+
}
|
|
1946
|
+
var UnknownVaultIdError, UnknownWorkspaceIdError;
|
|
1947
|
+
var init_account_pointer = __esm({
|
|
1948
|
+
"spex-console/src/lib/vault-registry/account-pointer.ts"() {
|
|
1949
|
+
"use strict";
|
|
1950
|
+
init_sql_orm_inert_cli_shim();
|
|
1951
|
+
init_postgres_client_cli_shim();
|
|
1952
|
+
init_postgres();
|
|
1953
|
+
init_neon_probe();
|
|
1954
|
+
init_registry_file();
|
|
1955
|
+
UnknownVaultIdError = class extends Error {
|
|
1956
|
+
vaultId;
|
|
1957
|
+
constructor(vaultId) {
|
|
1958
|
+
super(
|
|
1959
|
+
`setActiveVault: unknown vault id "${vaultId}" \u2014 no matching row in Neon "vaults" or registry.json's vaults[] list. Nothing was written.`
|
|
1960
|
+
);
|
|
1961
|
+
this.name = "UnknownVaultIdError";
|
|
1962
|
+
this.vaultId = vaultId;
|
|
1963
|
+
}
|
|
1964
|
+
};
|
|
1965
|
+
UnknownWorkspaceIdError = class extends Error {
|
|
1966
|
+
workspaceId;
|
|
1967
|
+
constructor(workspaceId) {
|
|
1968
|
+
super(
|
|
1969
|
+
`setActiveWorkspace: unknown workspace id "${workspaceId}" \u2014 no matching row in Neon "workspaces" or registry.json's workspaces[] list. Nothing was written.`
|
|
1970
|
+
);
|
|
1971
|
+
this.name = "UnknownWorkspaceIdError";
|
|
1972
|
+
this.workspaceId = workspaceId;
|
|
1973
|
+
}
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
});
|
|
1977
|
+
|
|
1978
|
+
// spex-console/src/lib/vault-registry/repo-identity.ts
|
|
1979
|
+
import path2 from "node:path";
|
|
1980
|
+
function normalizeRepoRoot(p) {
|
|
1981
|
+
let resolved = path2.resolve(p);
|
|
1982
|
+
while (resolved.length > 1 && (resolved.endsWith("/") || resolved.endsWith("\\"))) {
|
|
1983
|
+
resolved = resolved.slice(0, -1);
|
|
1984
|
+
}
|
|
1985
|
+
return resolved.toLowerCase();
|
|
1986
|
+
}
|
|
1987
|
+
function repoRootCandidates(p) {
|
|
1988
|
+
const norm = normalizeRepoRoot(p);
|
|
1989
|
+
const candidates = [norm];
|
|
1990
|
+
if (path2.basename(norm) === "specs") {
|
|
1991
|
+
const parent = path2.dirname(norm);
|
|
1992
|
+
if (parent !== norm) candidates.push(parent);
|
|
1993
|
+
}
|
|
1994
|
+
return candidates;
|
|
1995
|
+
}
|
|
1996
|
+
function repoRootsEquivalent(a, b) {
|
|
1997
|
+
if (!a || !b) return false;
|
|
1998
|
+
const aCands = repoRootCandidates(a);
|
|
1999
|
+
const bCands = new Set(repoRootCandidates(b));
|
|
2000
|
+
return aCands.some((c) => bCands.has(c));
|
|
2001
|
+
}
|
|
2002
|
+
var init_repo_identity = __esm({
|
|
2003
|
+
"spex-console/src/lib/vault-registry/repo-identity.ts"() {
|
|
2004
|
+
"use strict";
|
|
2005
|
+
}
|
|
2006
|
+
});
|
|
2007
|
+
|
|
2008
|
+
// spex-console/src/lib/cli/vault-workspace-shared.ts
|
|
2009
|
+
import { readFileSync as readFileSync5 } from "node:fs";
|
|
2010
|
+
import os2 from "node:os";
|
|
2011
|
+
import path3 from "node:path";
|
|
2012
|
+
function resolveCliAccountId(opts = {}) {
|
|
2013
|
+
const env = opts.env ?? process.env;
|
|
2014
|
+
const fromEnv = env.HS_ACCOUNT_ID?.trim();
|
|
2015
|
+
if (fromEnv) return fromEnv;
|
|
2016
|
+
try {
|
|
2017
|
+
const homeDir = opts.homeDir ?? os2.homedir();
|
|
2018
|
+
const readFile2 = opts.readFile ?? ((p) => readFileSync5(p, "utf-8"));
|
|
2019
|
+
const config = readFile2(path3.join(homeDir, ".heyspex", "config.yaml"));
|
|
2020
|
+
const match = config.match(/^account_id:\s*["']?([^"'\r\n#]+)/m);
|
|
2021
|
+
const value = match?.[1]?.trim();
|
|
2022
|
+
if (value && value.length > 0) return value;
|
|
2023
|
+
} catch {
|
|
2024
|
+
}
|
|
2025
|
+
return DEFAULT_ACCOUNT_ID;
|
|
2026
|
+
}
|
|
2027
|
+
async function cloudHttpRequest(http, method, path7, body) {
|
|
2028
|
+
try {
|
|
2029
|
+
const res = await http.fetchImpl(`${http.consoleUrl}${path7}`, {
|
|
2030
|
+
method,
|
|
2031
|
+
headers: {
|
|
2032
|
+
"content-type": "application/json",
|
|
2033
|
+
...http.token ? { authorization: `Bearer ${http.token}` } : {}
|
|
2034
|
+
},
|
|
2035
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
2036
|
+
});
|
|
2037
|
+
let json = null;
|
|
2038
|
+
try {
|
|
2039
|
+
json = await res.json();
|
|
2040
|
+
} catch {
|
|
2041
|
+
}
|
|
2042
|
+
return { ok: res.ok, status: res.status, json };
|
|
2043
|
+
} catch {
|
|
2044
|
+
return { ok: false, status: 0, json: null };
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
function shouldUseCloudHttp(deps) {
|
|
2048
|
+
return deps.db === void 0 && deps.http !== void 0 && !isPostgresConfigured();
|
|
2049
|
+
}
|
|
2050
|
+
async function upsertWorkspaceHttp(record, http) {
|
|
2051
|
+
const res = await cloudHttpRequest(http, "POST", "/api/workspaces", {
|
|
2052
|
+
id: record.id,
|
|
2053
|
+
display_name: record.displayName,
|
|
2054
|
+
...record.githubOrg ? { github_org: record.githubOrg } : {}
|
|
2055
|
+
});
|
|
2056
|
+
return res.status === 201 || res.status === 409;
|
|
2057
|
+
}
|
|
2058
|
+
async function upsertVaultHttp(record, http) {
|
|
2059
|
+
const res = await cloudHttpRequest(http, "POST", "/api/vaults", {
|
|
2060
|
+
id: record.id,
|
|
2061
|
+
workspace_id: record.workspaceId,
|
|
2062
|
+
display_name: record.displayName,
|
|
2063
|
+
repo_root_abs: record.repoRootAbs,
|
|
2064
|
+
schema_version: record.schemaVersion,
|
|
2065
|
+
...record.githubRemote ? { github_remote: record.githubRemote } : {}
|
|
2066
|
+
});
|
|
2067
|
+
return res.ok;
|
|
2068
|
+
}
|
|
2069
|
+
async function isNeonReachableForWrite(deps) {
|
|
2070
|
+
if (deps.neonReachable !== void 0) return deps.neonReachable;
|
|
2071
|
+
const usingInjectedDb = deps.db !== void 0;
|
|
2072
|
+
if (!usingInjectedDb && !isPostgresConfigured()) {
|
|
2073
|
+
emitProbeFailure(deps.probe, {
|
|
2074
|
+
reason: "not-configured",
|
|
2075
|
+
attempt: 1,
|
|
2076
|
+
attempts: 1,
|
|
2077
|
+
timeoutMs: 0,
|
|
2078
|
+
elapsedMs: 0,
|
|
2079
|
+
willRetry: false,
|
|
2080
|
+
error: new Error(
|
|
2081
|
+
"isPostgresConfigured() is false \u2014 no DATABASE_URL env var and no usable ~/.heyspex/credentials.yaml. Neon was never contacted."
|
|
2082
|
+
)
|
|
2083
|
+
});
|
|
2084
|
+
return false;
|
|
2085
|
+
}
|
|
2086
|
+
return runNeonReachabilityProbe(async () => {
|
|
2087
|
+
const db = deps.db ?? getPostgresDb();
|
|
2088
|
+
await db.execute(sql`select 1`);
|
|
2089
|
+
}, deps.probe);
|
|
2090
|
+
}
|
|
2091
|
+
async function upsertWorkspaceNeon(record, accountId2, deps = {}) {
|
|
2092
|
+
if (shouldUseCloudHttp(deps)) return upsertWorkspaceHttp(record, deps.http);
|
|
2093
|
+
if (!await isNeonReachableForWrite(deps)) return false;
|
|
2094
|
+
try {
|
|
2095
|
+
const db = deps.db ?? getPostgresDb();
|
|
2096
|
+
await db.insert(workspaces).values({
|
|
2097
|
+
id: record.id,
|
|
2098
|
+
accountId: accountId2,
|
|
2099
|
+
displayName: record.displayName,
|
|
2100
|
+
...record.githubOrg ? { githubOrg: record.githubOrg } : {}
|
|
2101
|
+
}).onConflictDoUpdate({
|
|
2102
|
+
target: workspaces.id,
|
|
2103
|
+
set: {
|
|
2104
|
+
displayName: record.displayName,
|
|
2105
|
+
...record.githubOrg ? { githubOrg: record.githubOrg } : {}
|
|
2106
|
+
}
|
|
2107
|
+
});
|
|
2108
|
+
return true;
|
|
2109
|
+
} catch {
|
|
2110
|
+
return false;
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
async function upsertVaultNeon(record, accountId2, deps = {}) {
|
|
2114
|
+
if (shouldUseCloudHttp(deps)) return upsertVaultHttp(record, deps.http);
|
|
2115
|
+
if (!await isNeonReachableForWrite(deps)) return false;
|
|
2116
|
+
try {
|
|
2117
|
+
const db = deps.db ?? getPostgresDb();
|
|
2118
|
+
await db.insert(vaults).values({
|
|
2119
|
+
id: record.id,
|
|
2120
|
+
workspaceId: record.workspaceId,
|
|
2121
|
+
accountId: accountId2,
|
|
2122
|
+
displayName: record.displayName,
|
|
2123
|
+
repoRootAbs: record.repoRootAbs,
|
|
2124
|
+
schemaVersion: record.schemaVersion,
|
|
2125
|
+
...record.githubRemote ? { githubRemote: record.githubRemote } : {}
|
|
2126
|
+
}).onConflictDoUpdate({
|
|
2127
|
+
target: vaults.id,
|
|
2128
|
+
set: {
|
|
2129
|
+
workspaceId: record.workspaceId,
|
|
2130
|
+
displayName: record.displayName,
|
|
2131
|
+
repoRootAbs: record.repoRootAbs,
|
|
2132
|
+
schemaVersion: record.schemaVersion,
|
|
2133
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
2134
|
+
...record.githubRemote ? { githubRemote: record.githubRemote } : {}
|
|
2135
|
+
}
|
|
2136
|
+
});
|
|
2137
|
+
return true;
|
|
2138
|
+
} catch {
|
|
2139
|
+
return false;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
async function findVaultWorkspaceIdNeon(vaultId, deps = {}) {
|
|
2143
|
+
if (!await isNeonReachableForWrite(deps)) return null;
|
|
2144
|
+
const db = deps.db ?? getPostgresDb();
|
|
2145
|
+
try {
|
|
2146
|
+
const rows = await db.select({ workspaceId: vaults.workspaceId }).from(vaults).where(eq(vaults.id, vaultId)).limit(1);
|
|
2147
|
+
return rows[0]?.workspaceId ?? null;
|
|
2148
|
+
} catch {
|
|
2149
|
+
return null;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
async function findVaultRowByIdNeon(vaultId, deps = {}) {
|
|
2153
|
+
if (!await isNeonReachableForWrite(deps)) return null;
|
|
2154
|
+
const db = deps.db ?? getPostgresDb();
|
|
2155
|
+
try {
|
|
2156
|
+
const rows = await db.select({ id: vaults.id, repoRootAbs: vaults.repoRootAbs }).from(vaults).where(eq(vaults.id, vaultId)).limit(1);
|
|
2157
|
+
const row = rows[0];
|
|
2158
|
+
return row ? { id: row.id, repoRootAbs: row.repoRootAbs } : null;
|
|
2159
|
+
} catch {
|
|
2160
|
+
return null;
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
async function listVaultsNeon(accountId2, deps = {}) {
|
|
2164
|
+
if (!await isNeonReachableForWrite(deps)) return null;
|
|
2165
|
+
const db = deps.db ?? getPostgresDb();
|
|
2166
|
+
try {
|
|
2167
|
+
const ownedRows = await db.select({
|
|
2168
|
+
id: vaults.id,
|
|
2169
|
+
workspaceId: vaults.workspaceId,
|
|
2170
|
+
displayName: vaults.displayName,
|
|
2171
|
+
repoRootAbs: vaults.repoRootAbs,
|
|
2172
|
+
githubRemote: vaults.githubRemote,
|
|
2173
|
+
schemaVersion: vaults.schemaVersion,
|
|
2174
|
+
createdAt: vaults.createdAt
|
|
2175
|
+
}).from(vaults).where(eq(vaults.accountId, accountId2));
|
|
2176
|
+
const grantedRows = await db.select({
|
|
2177
|
+
id: vaults.id,
|
|
2178
|
+
workspaceId: vaults.workspaceId,
|
|
2179
|
+
displayName: vaults.displayName,
|
|
2180
|
+
repoRootAbs: vaults.repoRootAbs,
|
|
2181
|
+
githubRemote: vaults.githubRemote,
|
|
2182
|
+
schemaVersion: vaults.schemaVersion,
|
|
2183
|
+
createdAt: vaults.createdAt
|
|
2184
|
+
}).from(vaultGrants).innerJoin(vaults, eq(vaults.id, vaultGrants.vaultId)).where(and(eq(vaultGrants.accountId, accountId2), ne(vaults.accountId, accountId2)));
|
|
2185
|
+
const toRecord = (r, granted) => ({
|
|
2186
|
+
id: r.id,
|
|
2187
|
+
workspace_id: r.workspaceId,
|
|
2188
|
+
display_name: r.displayName,
|
|
2189
|
+
// Registry-file rows require repo_root_abs; a cloud-only vault has none.
|
|
2190
|
+
// Surface it as an empty string so the row still lists (the switch
|
|
2191
|
+
// validation, not this list, decides selectability).
|
|
2192
|
+
repo_root_abs: r.repoRootAbs ?? "",
|
|
2193
|
+
schema_version: r.schemaVersion,
|
|
2194
|
+
registered_at: (r.createdAt instanceof Date ? r.createdAt : /* @__PURE__ */ new Date()).toISOString(),
|
|
2195
|
+
...r.githubRemote ? { github_remote: r.githubRemote } : {},
|
|
2196
|
+
...granted ? { granted: true } : {}
|
|
2197
|
+
});
|
|
2198
|
+
return [
|
|
2199
|
+
...ownedRows.map((r) => toRecord(r, false)),
|
|
2200
|
+
...grantedRows.map((r) => toRecord(r, true))
|
|
2201
|
+
];
|
|
2202
|
+
} catch {
|
|
2203
|
+
return null;
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
async function renameVaultNeon(id, displayName, accountId2, deps = {}) {
|
|
2207
|
+
if (!await isNeonReachableForWrite(deps)) return "unreachable";
|
|
2208
|
+
try {
|
|
2209
|
+
const db = deps.db ?? getPostgresDb();
|
|
2210
|
+
const rows = await db.update(vaults).set({ displayName, updatedAt: /* @__PURE__ */ new Date() }).where(and(eq(vaults.id, id), eq(vaults.accountId, accountId2))).returning({ id: vaults.id });
|
|
2211
|
+
return rows.length > 0 ? "updated" : "not_found";
|
|
2212
|
+
} catch {
|
|
2213
|
+
return "unreachable";
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
async function moveVaultNeon(id, workspaceId, accountId2, deps = {}) {
|
|
2217
|
+
if (!await isNeonReachableForWrite(deps)) return "unreachable";
|
|
2218
|
+
try {
|
|
2219
|
+
const db = deps.db ?? getPostgresDb();
|
|
2220
|
+
const rows = await db.update(vaults).set({ workspaceId, updatedAt: /* @__PURE__ */ new Date() }).where(and(eq(vaults.id, id), eq(vaults.accountId, accountId2))).returning({ id: vaults.id });
|
|
2221
|
+
return rows.length > 0 ? "updated" : "not_found";
|
|
2222
|
+
} catch {
|
|
2223
|
+
return "unreachable";
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
async function setVaultArchivedNeon(id, archived, accountId2, deps = {}) {
|
|
2227
|
+
if (!await isNeonReachableForWrite(deps)) return "unreachable";
|
|
2228
|
+
try {
|
|
2229
|
+
const db = deps.db ?? getPostgresDb();
|
|
2230
|
+
const rows = await db.update(vaults).set({ archived, updatedAt: /* @__PURE__ */ new Date() }).where(and(eq(vaults.id, id), eq(vaults.accountId, accountId2))).returning({ id: vaults.id });
|
|
2231
|
+
return rows.length > 0 ? "updated" : "not_found";
|
|
2232
|
+
} catch {
|
|
2233
|
+
return "unreachable";
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
async function deleteVaultNeon(id, accountId2, deps = {}) {
|
|
2237
|
+
if (!await isNeonReachableForWrite(deps)) return "unreachable";
|
|
2238
|
+
try {
|
|
2239
|
+
const db = deps.db ?? getPostgresDb();
|
|
2240
|
+
const rows = await db.delete(vaults).where(and(eq(vaults.id, id), eq(vaults.accountId, accountId2))).returning({ id: vaults.id });
|
|
2241
|
+
return rows.length > 0 ? "deleted" : "not_found";
|
|
2242
|
+
} catch {
|
|
2243
|
+
return "unreachable";
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
var DEFAULT_ACCOUNT_ID;
|
|
2247
|
+
var init_vault_workspace_shared = __esm({
|
|
2248
|
+
"spex-console/src/lib/cli/vault-workspace-shared.ts"() {
|
|
2249
|
+
"use strict";
|
|
2250
|
+
init_sql_orm_inert_cli_shim();
|
|
2251
|
+
init_postgres_client_cli_shim();
|
|
2252
|
+
init_interfaces();
|
|
2253
|
+
init_postgres();
|
|
2254
|
+
init_neon_probe();
|
|
2255
|
+
init_repo_identity();
|
|
2256
|
+
DEFAULT_ACCOUNT_ID = SINGLE_USER_ID;
|
|
2257
|
+
}
|
|
2258
|
+
});
|
|
2259
|
+
|
|
2260
|
+
// spex-console/src/lib/vault-resolve/index.ts
|
|
2261
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
2262
|
+
import path4 from "node:path";
|
|
2263
|
+
async function defaultRegistryLookup() {
|
|
2264
|
+
return null;
|
|
2265
|
+
}
|
|
2266
|
+
async function defaultAccountPointerLookup() {
|
|
2267
|
+
return null;
|
|
2268
|
+
}
|
|
2269
|
+
function finalize(vaultId, repoRootAbs, source) {
|
|
2270
|
+
const resolvedRoot = path4.resolve(repoRootAbs);
|
|
2271
|
+
return {
|
|
2272
|
+
vaultId,
|
|
2273
|
+
repoRootAbs: resolvedRoot,
|
|
2274
|
+
// Always derived the same way regardless of which layer resolved the
|
|
2275
|
+
// root, per this task's acceptance criteria.
|
|
2276
|
+
specsDirAbs: path4.join(resolvedRoot, "specs"),
|
|
2277
|
+
source
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
async function resolveActiveVaultRoot(options = {}) {
|
|
2281
|
+
const registryLookup = options.registryLookup ?? defaultRegistryLookup;
|
|
2282
|
+
const accountPointerLookup = options.accountPointerLookup ?? defaultAccountPointerLookup;
|
|
2283
|
+
const registryLookupByRoot = options.registryLookupByRoot ?? (() => null);
|
|
2284
|
+
const onTier = (event) => {
|
|
2285
|
+
try {
|
|
2286
|
+
options.onTier?.(event);
|
|
2287
|
+
} catch {
|
|
2288
|
+
}
|
|
2289
|
+
};
|
|
2290
|
+
const finalizePathTier = async (repoRootAbs, source) => {
|
|
2291
|
+
const resolvedRoot = path4.resolve(repoRootAbs);
|
|
2292
|
+
const recovered = await registryLookupByRoot(resolvedRoot);
|
|
2293
|
+
if (recovered) return finalize(recovered.id, resolvedRoot, source);
|
|
2294
|
+
return finalize(UNREGISTERED_VAULT_ID, resolvedRoot, source);
|
|
2295
|
+
};
|
|
2296
|
+
if (!options.vaultId) {
|
|
2297
|
+
onTier({ tier: "explicit", outcome: "skipped", detail: "no explicit vaultId given" });
|
|
2298
|
+
}
|
|
2299
|
+
if (options.vaultId) {
|
|
2300
|
+
const record = await registryLookup(options.vaultId);
|
|
2301
|
+
if (record) {
|
|
2302
|
+
onTier({ tier: "explicit", outcome: "resolved", detail: record.id });
|
|
2303
|
+
return finalize(record.id, record.repoRootAbs, "explicit");
|
|
2304
|
+
}
|
|
2305
|
+
onTier({
|
|
2306
|
+
tier: "explicit",
|
|
2307
|
+
outcome: "no-registry-row",
|
|
2308
|
+
detail: `explicit vaultId "${options.vaultId}" has no registry.json row and no Neon row`
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
const pointerVaultId = await accountPointerLookup(options.accountId);
|
|
2312
|
+
if (pointerVaultId) {
|
|
2313
|
+
const record = await registryLookup(pointerVaultId);
|
|
2314
|
+
if (record) {
|
|
2315
|
+
onTier({ tier: "account-pointer", outcome: "resolved", detail: record.id });
|
|
2316
|
+
return finalize(record.id, record.repoRootAbs, "account-pointer");
|
|
2317
|
+
}
|
|
2318
|
+
onTier({
|
|
2319
|
+
tier: "account-pointer",
|
|
2320
|
+
outcome: "no-registry-row",
|
|
2321
|
+
detail: `account pointer says "${pointerVaultId}" but no registry.json / Neon row backs it`
|
|
2322
|
+
});
|
|
2323
|
+
} else {
|
|
2324
|
+
onTier({
|
|
2325
|
+
tier: "account-pointer",
|
|
2326
|
+
outcome: "empty",
|
|
2327
|
+
detail: `no active_vault_id for account "${options.accountId ?? "(default)"}" (Neon account_settings unreachable/unset, and registry.json had none)`
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
const envRoot = process.env.HEYSPEX_VAULT_ROOT;
|
|
2331
|
+
if (envRoot) {
|
|
2332
|
+
onTier({ tier: "env-vault-root", outcome: "resolved", detail: envRoot });
|
|
2333
|
+
return finalizePathTier(path4.resolve(envRoot), "env-vault-root");
|
|
2334
|
+
}
|
|
2335
|
+
onTier({ tier: "env-vault-root", outcome: "skipped", detail: "HEYSPEX_VAULT_ROOT unset" });
|
|
2336
|
+
const envDir = process.env.HEYSPEX_VAULT_DIR;
|
|
2337
|
+
if (envDir) {
|
|
2338
|
+
const specsDirFromEnv = path4.resolve(envDir);
|
|
2339
|
+
onTier({ tier: "env-vault-dir", outcome: "resolved", detail: envDir });
|
|
2340
|
+
return finalizePathTier(path4.dirname(specsDirFromEnv), "env-vault-dir");
|
|
2341
|
+
}
|
|
2342
|
+
onTier({ tier: "env-vault-dir", outcome: "skipped", detail: "HEYSPEX_VAULT_DIR unset" });
|
|
2343
|
+
const cwdAbs = path4.resolve(options.cwd ?? process.cwd());
|
|
2344
|
+
if (existsSync2(path4.join(cwdAbs, "specs"))) {
|
|
2345
|
+
onTier({ tier: "cwd-heuristic", outcome: "resolved", detail: cwdAbs });
|
|
2346
|
+
return finalizePathTier(cwdAbs, "cwd-heuristic");
|
|
2347
|
+
}
|
|
2348
|
+
const parentAbs = path4.resolve(cwdAbs, "..");
|
|
2349
|
+
if (existsSync2(path4.join(parentAbs, "specs"))) {
|
|
2350
|
+
onTier({ tier: "cwd-heuristic", outcome: "resolved", detail: parentAbs });
|
|
2351
|
+
return finalizePathTier(parentAbs, "cwd-heuristic");
|
|
2352
|
+
}
|
|
2353
|
+
onTier({
|
|
2354
|
+
tier: "cwd-heuristic",
|
|
2355
|
+
outcome: "empty",
|
|
2356
|
+
detail: `no specs/ directory at ${cwdAbs} or ${parentAbs}`
|
|
2357
|
+
});
|
|
2358
|
+
throw new NoActiveVaultError();
|
|
2359
|
+
}
|
|
2360
|
+
var UNREGISTERED_VAULT_ID, NoActiveVaultError;
|
|
2361
|
+
var init_vault_resolve = __esm({
|
|
2362
|
+
"spex-console/src/lib/vault-resolve/index.ts"() {
|
|
2363
|
+
"use strict";
|
|
2364
|
+
UNREGISTERED_VAULT_ID = "unregistered";
|
|
2365
|
+
NoActiveVaultError = class _NoActiveVaultError extends Error {
|
|
2366
|
+
constructor(message = "resolveActiveVaultRoot: no active vault found -- no explicit vaultId, no account active_vault_id, no HEYSPEX_VAULT_ROOT/HEYSPEX_VAULT_DIR env, and no specs/ directory found at cwd or its parent. Run `hs vault register` to register a vault.") {
|
|
2367
|
+
super(message);
|
|
2368
|
+
this.name = "NoActiveVaultError";
|
|
2369
|
+
Object.setPrototypeOf(this, _NoActiveVaultError.prototype);
|
|
2370
|
+
}
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
});
|
|
2374
|
+
|
|
2375
|
+
// spex-console/src/lib/vault-resolve/resolve-status.ts
|
|
2376
|
+
function emptyStatus() {
|
|
2377
|
+
return {
|
|
2378
|
+
state: "never-attempted",
|
|
2379
|
+
context: null,
|
|
2380
|
+
attempts: 0,
|
|
2381
|
+
resolvedVaultId: null,
|
|
2382
|
+
resolvedSource: null,
|
|
2383
|
+
lastError: null,
|
|
2384
|
+
tierTrace: [],
|
|
2385
|
+
probeFailures: [],
|
|
2386
|
+
startedAt: null,
|
|
2387
|
+
updatedAt: null
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
function getVaultResolveStatus() {
|
|
2391
|
+
return carrier.status;
|
|
2392
|
+
}
|
|
2393
|
+
function markVaultResolveStarted(context, attempt) {
|
|
2394
|
+
const status = carrier.status;
|
|
2395
|
+
status.state = "in-progress";
|
|
2396
|
+
status.context = context;
|
|
2397
|
+
status.attempts = attempt;
|
|
2398
|
+
status.updatedAt = Date.now();
|
|
2399
|
+
if (attempt <= 1) {
|
|
2400
|
+
status.startedAt = Date.now();
|
|
2401
|
+
status.tierTrace = [];
|
|
2402
|
+
status.probeFailures = [];
|
|
2403
|
+
status.lastError = null;
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
function recordVaultResolveTier(event) {
|
|
2407
|
+
if (carrier.status.tierTrace.length >= 64) return;
|
|
2408
|
+
carrier.status.tierTrace.push(event);
|
|
2409
|
+
}
|
|
2410
|
+
function recordNeonProbeFailure(failure) {
|
|
2411
|
+
if (carrier.status.probeFailures.length >= 32) return;
|
|
2412
|
+
carrier.status.probeFailures.push(failure);
|
|
2413
|
+
}
|
|
2414
|
+
function markVaultResolveSucceeded(resolved) {
|
|
2415
|
+
const status = carrier.status;
|
|
2416
|
+
status.state = "resolved";
|
|
2417
|
+
status.resolvedVaultId = resolved.vaultId;
|
|
2418
|
+
status.resolvedSource = resolved.source;
|
|
2419
|
+
status.lastError = null;
|
|
2420
|
+
status.updatedAt = Date.now();
|
|
2421
|
+
}
|
|
2422
|
+
function markVaultResolveFailed(err) {
|
|
2423
|
+
const status = carrier.status;
|
|
2424
|
+
status.state = "failed";
|
|
2425
|
+
status.resolvedVaultId = null;
|
|
2426
|
+
status.resolvedSource = null;
|
|
2427
|
+
status.lastError = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
2428
|
+
status.updatedAt = Date.now();
|
|
2429
|
+
}
|
|
2430
|
+
function formatProbeFailure(f) {
|
|
2431
|
+
return `attempt ${f.attempt}/${f.attempts} ${f.reason} after ${f.elapsedMs}ms (budget ${f.timeoutMs}ms, willRetry=${f.willRetry}): ${f.message}`;
|
|
2432
|
+
}
|
|
2433
|
+
function formatVaultResolveEvidence(status = carrier.status) {
|
|
2434
|
+
const lines = [];
|
|
2435
|
+
lines.push(
|
|
2436
|
+
` state=${status.state} context=${status.context ?? "n/a"} attempts=${status.attempts}` + (status.startedAt && status.updatedAt ? ` elapsed=${status.updatedAt - status.startedAt}ms` : "")
|
|
2437
|
+
);
|
|
2438
|
+
if (status.lastError) lines.push(` error: ${status.lastError}`);
|
|
2439
|
+
if (status.tierTrace.length > 0) {
|
|
2440
|
+
lines.push(" precedence tiers tried:");
|
|
2441
|
+
for (const t of status.tierTrace) {
|
|
2442
|
+
lines.push(` - ${t.tier}: ${t.outcome}${t.detail ? ` (${t.detail})` : ""}`);
|
|
2443
|
+
}
|
|
2444
|
+
} else {
|
|
2445
|
+
lines.push(" precedence tiers tried: (none recorded \u2014 no resolve ran in this process)");
|
|
2446
|
+
}
|
|
2447
|
+
if (status.probeFailures.length > 0) {
|
|
2448
|
+
lines.push(" Neon reachability probe failures:");
|
|
2449
|
+
for (const f of status.probeFailures) lines.push(` - ${formatProbeFailure(f)}`);
|
|
2450
|
+
}
|
|
2451
|
+
return lines.join("\n");
|
|
2452
|
+
}
|
|
2453
|
+
function reportVaultResolveFailure(context, err) {
|
|
2454
|
+
const header = context === "boot" ? `[vault-resolve] BOOT VAULT RESOLVE FAILED \u2014 ${CONSEQUENCE}` : `[vault-resolve] VAULT RESOLVE FAILED (context=${context}).`;
|
|
2455
|
+
const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
2456
|
+
console.error(`${header}
|
|
2457
|
+
thrown: ${detail}
|
|
2458
|
+
${formatVaultResolveEvidence()}
|
|
2459
|
+
${FIX_HINT}`);
|
|
2460
|
+
}
|
|
2461
|
+
function reportVaultResolveSuccess(context, resolved) {
|
|
2462
|
+
const status = carrier.status;
|
|
2463
|
+
console.log(
|
|
2464
|
+
`[vault-resolve] ${context} resolved vault "${resolved.vaultId}" via ${resolved.source} (root=${resolved.repoRootAbs}, attempts=${status.attempts})`
|
|
2465
|
+
);
|
|
2466
|
+
}
|
|
2467
|
+
function reportColdVaultScopeOnce() {
|
|
2468
|
+
if (carrier.coldScopeReported) return;
|
|
2469
|
+
carrier.coldScopeReported = true;
|
|
2470
|
+
if (process.env.VITEST) return;
|
|
2471
|
+
const status = carrier.status;
|
|
2472
|
+
const cause = status.state === "failed" ? "the boot resolve was ATTEMPTED AND FAILED" : status.state === "in-progress" ? "the boot resolve is STILL RUNNING (this request beat it)" : status.state === "resolved" ? "a resolve SUCCEEDED but the sync cache was cleared afterwards" : "NO resolve has run in this process yet (deferred boot has not fired)";
|
|
2473
|
+
console.error(
|
|
2474
|
+
`[vault-resolve] COLD VAULT SCOPE \u2014 a read fell back to vault_id="default" because ${cause}. ${CONSEQUENCE}
|
|
2475
|
+
${formatVaultResolveEvidence()}
|
|
2476
|
+
${FIX_HINT}`
|
|
2477
|
+
);
|
|
2478
|
+
}
|
|
2479
|
+
var STATUS_KEY, carrier, CONSEQUENCE, FIX_HINT;
|
|
2480
|
+
var init_resolve_status = __esm({
|
|
2481
|
+
"spex-console/src/lib/vault-resolve/resolve-status.ts"() {
|
|
2482
|
+
"use strict";
|
|
2483
|
+
STATUS_KEY = Symbol.for("heyspex.vaultResolveStatus");
|
|
2484
|
+
carrier = (() => {
|
|
2485
|
+
const g = globalThis;
|
|
2486
|
+
if (!g[STATUS_KEY]) g[STATUS_KEY] = { status: emptyStatus(), coldScopeReported: false };
|
|
2487
|
+
return g[STATUS_KEY];
|
|
2488
|
+
})();
|
|
2489
|
+
CONSEQUENCE = 'Reads on this process serve vault_id="default" \u2014 an EMPTY bucket \u2014 so process-scoped reads 404/serve empty until a resolve lands. Cold reads kick a background self-heal resolve (gap-limited), so this heals in-place instead of waiting for the instance to be replaced.';
|
|
2490
|
+
FIX_HINT = " Fix: confirm DATABASE_URL is set for this deployment and that Neon account_settings.active_vault_id is populated for the account above. If the probe failures show connect timeouts, the Neon compute is waking from scale-to-zero \u2014 raise HEYSPEX_NEON_BOOT_PROBE_TIMEOUT_MS / HEYSPEX_NEON_BOOT_PROBE_DEADLINE_MS.";
|
|
2491
|
+
}
|
|
2492
|
+
});
|
|
2493
|
+
|
|
2494
|
+
// spex-console/src/lib/vault-resolve/active.ts
|
|
2495
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
2496
|
+
import path5 from "node:path";
|
|
2497
|
+
async function defaultNeonReadTierEnabled() {
|
|
2498
|
+
const { isNeonReadTierEnabled: isNeonReadTierEnabled2 } = await Promise.resolve().then(() => (init_spec_read_service(), spec_read_service_exports));
|
|
2499
|
+
return isNeonReadTierEnabled2();
|
|
2500
|
+
}
|
|
2501
|
+
async function defaultNeonVaultLookup(vaultId, deps) {
|
|
2502
|
+
if (deps.neonReachable === false) return null;
|
|
2503
|
+
return findVaultRowByIdNeon(vaultId, deps);
|
|
2504
|
+
}
|
|
2505
|
+
function instrumentedProbeOptions(options) {
|
|
2506
|
+
const inner = options.neonProbe;
|
|
2507
|
+
return {
|
|
2508
|
+
...inner,
|
|
2509
|
+
onFailure: (failure) => {
|
|
2510
|
+
recordNeonProbeFailure({
|
|
2511
|
+
reason: failure.reason,
|
|
2512
|
+
attempt: failure.attempt,
|
|
2513
|
+
attempts: failure.attempts,
|
|
2514
|
+
timeoutMs: failure.timeoutMs,
|
|
2515
|
+
elapsedMs: failure.elapsedMs,
|
|
2516
|
+
willRetry: failure.willRetry,
|
|
2517
|
+
message: failure.error instanceof Error ? failure.error.message : String(failure.error)
|
|
2518
|
+
});
|
|
2519
|
+
inner?.onFailure?.(failure);
|
|
2520
|
+
}
|
|
2521
|
+
};
|
|
2522
|
+
}
|
|
2523
|
+
function buildRegistryLookup(options) {
|
|
2524
|
+
const { registryOpts, neonReachable } = options;
|
|
2525
|
+
const neonVaultLookup = options.neonVaultLookup ?? defaultNeonVaultLookup;
|
|
2526
|
+
const neonReadTierEnabled = options.neonReadTierEnabled ?? defaultNeonReadTierEnabled;
|
|
2527
|
+
const probe = instrumentedProbeOptions(options);
|
|
2528
|
+
return async (vaultId) => {
|
|
2529
|
+
const registry = loadVaultRegistry(registryOpts);
|
|
2530
|
+
const record = registry.vaults.find((v) => v.id === vaultId);
|
|
2531
|
+
if (record) return { id: record.id, repoRootAbs: record.repo_root_abs };
|
|
2532
|
+
const neonRow = await neonVaultLookup(vaultId, { neonReachable, probe });
|
|
2533
|
+
if (!neonRow) return null;
|
|
2534
|
+
if (neonRow.repoRootAbs && existsSync3(neonRow.repoRootAbs)) {
|
|
2535
|
+
return { id: neonRow.id, repoRootAbs: neonRow.repoRootAbs };
|
|
2536
|
+
}
|
|
2537
|
+
if (await neonReadTierEnabled()) {
|
|
2538
|
+
return { id: neonRow.id, repoRootAbs: fallbackRepoRoot() };
|
|
2539
|
+
}
|
|
2540
|
+
return neonRow.repoRootAbs ? { id: neonRow.id, repoRootAbs: neonRow.repoRootAbs } : null;
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
function buildRegistryLookupByRoot(registryOpts) {
|
|
2544
|
+
return (repoRootAbs) => {
|
|
2545
|
+
const registry = loadVaultRegistry(registryOpts);
|
|
2546
|
+
const record = registry.vaults.find((v) => repoRootsEquivalent(v.repo_root_abs, repoRootAbs));
|
|
2547
|
+
return record ? { id: record.id, repoRootAbs: record.repo_root_abs } : null;
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2550
|
+
function buildAccountPointerLookup(accountId2, options) {
|
|
2551
|
+
const { registryOpts, neonReachable } = options;
|
|
2552
|
+
const probe = instrumentedProbeOptions(options);
|
|
2553
|
+
return (accId) => getActiveVault(accId ?? accountId2, { registryOpts, neonReachable, probe });
|
|
2554
|
+
}
|
|
2555
|
+
async function ensureActiveVaultResolved(options = {}) {
|
|
2556
|
+
if (cacheStore.value && !options.force) return cacheStore.value;
|
|
2557
|
+
const accountId2 = options.accountId ?? SINGLE_USER_ID;
|
|
2558
|
+
markVaultResolveStarted(options.context ?? "unknown", options.attempt ?? 1);
|
|
2559
|
+
let resolved;
|
|
2560
|
+
try {
|
|
2561
|
+
resolved = await resolveActiveVaultRoot({
|
|
2562
|
+
vaultId: options.vaultId,
|
|
2563
|
+
accountId: accountId2,
|
|
2564
|
+
cwd: options.cwd,
|
|
2565
|
+
registryLookup: buildRegistryLookup(options),
|
|
2566
|
+
accountPointerLookup: buildAccountPointerLookup(accountId2, options),
|
|
2567
|
+
registryLookupByRoot: buildRegistryLookupByRoot(options.registryOpts),
|
|
2568
|
+
onTier: recordVaultResolveTier
|
|
2569
|
+
});
|
|
2570
|
+
} catch (err) {
|
|
2571
|
+
markVaultResolveFailed(err);
|
|
2572
|
+
throw err;
|
|
2573
|
+
}
|
|
2574
|
+
if (resolved.vaultId === UNREGISTERED_VAULT_ID) {
|
|
2575
|
+
console.error(
|
|
2576
|
+
`[vault-resolve] ACTIVE VAULT IS UNREGISTERED \u2014 resolved ${resolved.repoRootAbs} via ${resolved.source} with no matching registry row. Specs will be indexed under the "unregistered" sentinel and INVISIBLE to registered-vault reads. Fix: \`hs vault register\` this repo (or reconcile the vault id across ~/.heyspex/registry.json and Neon \`vaults\` \u2014 one repo, one id).`
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
markVaultResolveSucceeded(resolved);
|
|
2580
|
+
cacheStore.value = resolved;
|
|
2581
|
+
return resolved;
|
|
2582
|
+
}
|
|
2583
|
+
function fallbackSpecsDir() {
|
|
2584
|
+
const override = process.env.HEYSPEX_VAULT_DIR;
|
|
2585
|
+
if (override) return path5.resolve(override);
|
|
2586
|
+
return path5.resolve(process.cwd(), "..", "specs");
|
|
2587
|
+
}
|
|
2588
|
+
function fallbackRepoRoot() {
|
|
2589
|
+
return path5.resolve(fallbackSpecsDir(), "..");
|
|
2590
|
+
}
|
|
2591
|
+
function getActiveVaultId() {
|
|
2592
|
+
return cacheStore.value ? cacheStore.value.vaultId : null;
|
|
2593
|
+
}
|
|
2594
|
+
var ACTIVE_VAULT_CACHE_KEY, cacheStore;
|
|
2595
|
+
var init_active = __esm({
|
|
2596
|
+
"spex-console/src/lib/vault-resolve/active.ts"() {
|
|
2597
|
+
"use strict";
|
|
2598
|
+
init_vault_workspace_shared();
|
|
2599
|
+
init_interfaces();
|
|
2600
|
+
init_account_pointer();
|
|
2601
|
+
init_registry_file();
|
|
2602
|
+
init_repo_identity();
|
|
2603
|
+
init_vault_resolve();
|
|
2604
|
+
init_resolve_status();
|
|
2605
|
+
ACTIVE_VAULT_CACHE_KEY = Symbol.for("heyspex.activeVaultCache");
|
|
2606
|
+
cacheStore = (() => {
|
|
2607
|
+
const carrier2 = globalThis;
|
|
2608
|
+
if (!carrier2[ACTIVE_VAULT_CACHE_KEY]) carrier2[ACTIVE_VAULT_CACHE_KEY] = { value: null };
|
|
2609
|
+
return carrier2[ACTIVE_VAULT_CACHE_KEY];
|
|
2610
|
+
})();
|
|
2611
|
+
}
|
|
2612
|
+
});
|
|
2613
|
+
|
|
2614
|
+
// spex-console/src/lib/vault-resolve/rebind.ts
|
|
2615
|
+
function sleep2(ms) {
|
|
2616
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2617
|
+
}
|
|
2618
|
+
async function resolveWithBootRetry(options, context) {
|
|
2619
|
+
const { vaultId, boot = false } = options;
|
|
2620
|
+
if (!boot) {
|
|
2621
|
+
return ensureActiveVaultResolved({ force: true, vaultId, context });
|
|
2622
|
+
}
|
|
2623
|
+
const backoffMs = options.bootRetryBackoffMs ?? BOOT_RESOLVE_BACKOFF_MS;
|
|
2624
|
+
const attempts = vaultId ? 1 : backoffMs.length + 1;
|
|
2625
|
+
const startedAt = Date.now();
|
|
2626
|
+
let lastErr;
|
|
2627
|
+
for (let i = 0; i < attempts; i++) {
|
|
2628
|
+
try {
|
|
2629
|
+
return await ensureActiveVaultResolved({
|
|
2630
|
+
vaultId,
|
|
2631
|
+
context,
|
|
2632
|
+
attempt: i + 1,
|
|
2633
|
+
neonProbe: bootNeonProbeOptions()
|
|
2634
|
+
});
|
|
2635
|
+
} catch (err) {
|
|
2636
|
+
lastErr = err;
|
|
2637
|
+
const isLast = i === attempts - 1;
|
|
2638
|
+
if (isLast || Date.now() - startedAt >= BOOT_RESOLVE_DEADLINE_MS) break;
|
|
2639
|
+
console.warn(
|
|
2640
|
+
`[rebindActiveVault] boot vault resolve attempt ${i + 1}/${attempts} failed (${err instanceof Error ? err.message : String(err)}) \u2014 retrying in ${backoffMs[i]}ms`
|
|
2641
|
+
);
|
|
2642
|
+
await sleep2(backoffMs[i]);
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
throw lastErr;
|
|
2646
|
+
}
|
|
2647
|
+
function startBootVaultResolve(options = {}) {
|
|
2648
|
+
const run = async () => {
|
|
2649
|
+
try {
|
|
2650
|
+
const resolved = await resolveWithBootRetry({ ...options, boot: true }, "boot");
|
|
2651
|
+
reportVaultResolveSuccess("boot", resolved);
|
|
2652
|
+
return { resolved, error: null };
|
|
2653
|
+
} catch (err) {
|
|
2654
|
+
reportVaultResolveFailure("boot", err);
|
|
2655
|
+
return { resolved: null, error: err };
|
|
2656
|
+
}
|
|
2657
|
+
};
|
|
2658
|
+
if (options.vaultId) return run();
|
|
2659
|
+
if (bootResolveCarrier.inflight) return bootResolveCarrier.inflight;
|
|
2660
|
+
const inflight = run().finally(() => {
|
|
2661
|
+
if (bootResolveCarrier.inflight === inflight) bootResolveCarrier.inflight = null;
|
|
2662
|
+
});
|
|
2663
|
+
bootResolveCarrier.inflight = inflight;
|
|
2664
|
+
return inflight;
|
|
2665
|
+
}
|
|
2666
|
+
function kickVaultResolveSelfHeal() {
|
|
2667
|
+
if (process.env.VITEST) return;
|
|
2668
|
+
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
|
2669
|
+
if (bootResolveCarrier.inflight) return;
|
|
2670
|
+
if (getVaultResolveStatus().state === "in-progress") return;
|
|
2671
|
+
const now = Date.now();
|
|
2672
|
+
if (now < bootResolveCarrier.nextSelfHealAt) return;
|
|
2673
|
+
bootResolveCarrier.nextSelfHealAt = now + SELF_HEAL_RETRY_GAP_MS;
|
|
2674
|
+
void startBootVaultResolve();
|
|
2675
|
+
}
|
|
2676
|
+
var BOOT_RESOLVE_BACKOFF_MS, BOOT_RESOLVE_DEADLINE_MS, BOOT_RESOLVE_KEY, bootResolveCarrier, SELF_HEAL_RETRY_GAP_MS;
|
|
2677
|
+
var init_rebind = __esm({
|
|
2678
|
+
"spex-console/src/lib/vault-resolve/rebind.ts"() {
|
|
2679
|
+
"use strict";
|
|
2680
|
+
init_neon_probe();
|
|
2681
|
+
init_active();
|
|
2682
|
+
init_resolve_status();
|
|
2683
|
+
BOOT_RESOLVE_BACKOFF_MS = [1e3, 4e3];
|
|
2684
|
+
BOOT_RESOLVE_DEADLINE_MS = 45e3;
|
|
2685
|
+
BOOT_RESOLVE_KEY = Symbol.for("heyspex.bootVaultResolve");
|
|
2686
|
+
bootResolveCarrier = (() => {
|
|
2687
|
+
const g = globalThis;
|
|
2688
|
+
if (!g[BOOT_RESOLVE_KEY]) g[BOOT_RESOLVE_KEY] = { inflight: null, nextSelfHealAt: 0 };
|
|
2689
|
+
return g[BOOT_RESOLVE_KEY];
|
|
2690
|
+
})();
|
|
2691
|
+
SELF_HEAL_RETRY_GAP_MS = 3e4;
|
|
2692
|
+
}
|
|
2693
|
+
});
|
|
2694
|
+
|
|
2695
|
+
// spex-console/src/lib/vault-resolve/scope.ts
|
|
2696
|
+
function resolveVaultScopeId() {
|
|
2697
|
+
const active = getActiveVaultId();
|
|
2698
|
+
if (active !== null) return active;
|
|
2699
|
+
kickVaultResolveSelfHeal();
|
|
2700
|
+
reportColdVaultScopeOnce();
|
|
2701
|
+
return DEFAULT_VAULT_ID;
|
|
2702
|
+
}
|
|
2703
|
+
var init_scope = __esm({
|
|
2704
|
+
"spex-console/src/lib/vault-resolve/scope.ts"() {
|
|
2705
|
+
"use strict";
|
|
2706
|
+
init_interfaces();
|
|
2707
|
+
init_active();
|
|
2708
|
+
init_rebind();
|
|
2709
|
+
init_resolve_status();
|
|
2710
|
+
}
|
|
2711
|
+
});
|
|
2712
|
+
|
|
2713
|
+
// spex-console/src/lib/scheduler/types.ts
|
|
2714
|
+
var init_types2 = __esm({
|
|
2715
|
+
"spex-console/src/lib/scheduler/types.ts"() {
|
|
2716
|
+
"use strict";
|
|
2717
|
+
}
|
|
2718
|
+
});
|
|
2719
|
+
|
|
2720
|
+
// spex-console/src/lib/scheduler/comparators.ts
|
|
2721
|
+
var init_comparators = __esm({
|
|
2722
|
+
"spex-console/src/lib/scheduler/comparators.ts"() {
|
|
2723
|
+
"use strict";
|
|
2724
|
+
init_types2();
|
|
2725
|
+
}
|
|
2726
|
+
});
|
|
2727
|
+
|
|
2728
|
+
// spex-console/src/lib/vault-id.ts
|
|
2729
|
+
var init_vault_id = __esm({
|
|
2730
|
+
"spex-console/src/lib/vault-id.ts"() {
|
|
2731
|
+
"use strict";
|
|
2732
|
+
}
|
|
2733
|
+
});
|
|
2734
|
+
|
|
2735
|
+
// spex-console/src/lib/scheduler/toposort.ts
|
|
2736
|
+
var init_toposort = __esm({
|
|
2737
|
+
"spex-console/src/lib/scheduler/toposort.ts"() {
|
|
2738
|
+
"use strict";
|
|
2739
|
+
init_vault_id();
|
|
2740
|
+
}
|
|
2741
|
+
});
|
|
2742
|
+
|
|
2743
|
+
// spex-console/src/lib/scheduler/criticality.ts
|
|
2744
|
+
var init_criticality = __esm({
|
|
2745
|
+
"spex-console/src/lib/scheduler/criticality.ts"() {
|
|
2746
|
+
"use strict";
|
|
2747
|
+
init_toposort();
|
|
2748
|
+
}
|
|
2749
|
+
});
|
|
2750
|
+
|
|
2751
|
+
// spex-console/src/lib/scheduler/explanation.ts
|
|
2752
|
+
var init_explanation = __esm({
|
|
2753
|
+
"spex-console/src/lib/scheduler/explanation.ts"() {
|
|
2754
|
+
"use strict";
|
|
2755
|
+
init_comparators();
|
|
2756
|
+
init_types2();
|
|
2757
|
+
}
|
|
2758
|
+
});
|
|
2759
|
+
|
|
2760
|
+
// spex-console/src/lib/scheduler/pipeline.ts
|
|
2761
|
+
var init_pipeline = __esm({
|
|
2762
|
+
"spex-console/src/lib/scheduler/pipeline.ts"() {
|
|
2763
|
+
"use strict";
|
|
2764
|
+
init_comparators();
|
|
2765
|
+
init_criticality();
|
|
2766
|
+
init_explanation();
|
|
2767
|
+
init_toposort();
|
|
2768
|
+
init_types2();
|
|
2769
|
+
}
|
|
2770
|
+
});
|
|
2771
|
+
|
|
2772
|
+
// spex-console/src/lib/scheduler/index.ts
|
|
2773
|
+
var init_scheduler = __esm({
|
|
2774
|
+
"spex-console/src/lib/scheduler/index.ts"() {
|
|
2775
|
+
"use strict";
|
|
2776
|
+
init_comparators();
|
|
2777
|
+
init_criticality();
|
|
2778
|
+
init_explanation();
|
|
2779
|
+
init_pipeline();
|
|
2780
|
+
init_toposort();
|
|
2781
|
+
init_types2();
|
|
2782
|
+
}
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
// spex-console/src/lib/db/repositories/helpers.ts
|
|
2786
|
+
function buildFrontmatter(row, tags2, aliases2, extraFields) {
|
|
2787
|
+
const fm = {
|
|
2788
|
+
id: row.id,
|
|
2789
|
+
name: row.name,
|
|
2790
|
+
type: row.type,
|
|
2791
|
+
status: row.status,
|
|
2792
|
+
...row.owner ? { owner: row.owner } : {},
|
|
2793
|
+
created: row.created,
|
|
2794
|
+
updated: row.updated,
|
|
2795
|
+
...tags2.length > 0 ? { tags: tags2 } : {},
|
|
2796
|
+
...aliases2.length > 0 ? { aliases: aliases2 } : {}
|
|
2797
|
+
};
|
|
2798
|
+
for (const [field, value] of Object.entries(extraFields)) {
|
|
2799
|
+
if (fm[field] !== void 0) continue;
|
|
2800
|
+
fm[field] = value;
|
|
2801
|
+
}
|
|
2802
|
+
return fm;
|
|
2803
|
+
}
|
|
2804
|
+
var init_helpers = __esm({
|
|
2805
|
+
"spex-console/src/lib/db/repositories/helpers.ts"() {
|
|
2806
|
+
"use strict";
|
|
2807
|
+
init_scheduler();
|
|
2808
|
+
}
|
|
2809
|
+
});
|
|
2810
|
+
|
|
2811
|
+
// spex-console/src/lib/db/repositories/spec-read-service.ts
|
|
2812
|
+
var spec_read_service_exports = {};
|
|
2813
|
+
__export(spec_read_service_exports, {
|
|
2814
|
+
fetchAllSpecBundlesFromNeon: () => fetchAllSpecBundlesFromNeon,
|
|
2815
|
+
frontmatterFromNeonBundle: () => frontmatterFromNeonBundle,
|
|
2816
|
+
isNeonReadTierEnabled: () => isNeonReadTierEnabled,
|
|
2817
|
+
resolveReadTier: () => resolveReadTier
|
|
2818
|
+
});
|
|
2819
|
+
function resolveReadTier() {
|
|
2820
|
+
const raw = process.env.HEYSPEX_READ_TIER?.trim().toLowerCase();
|
|
2821
|
+
return raw === "neon" ? "neon" : "disk";
|
|
2822
|
+
}
|
|
2823
|
+
function isNeonReadTierEnabled() {
|
|
2824
|
+
return resolveReadTier() === "neon" && isPostgresUsable();
|
|
2825
|
+
}
|
|
2826
|
+
async function fmTagsForMany(db, specIds, vaultScopeId) {
|
|
2827
|
+
const out = /* @__PURE__ */ new Map();
|
|
2828
|
+
if (specIds.length === 0) return out;
|
|
2829
|
+
const rows = await db.select({ specId: tags.specId, tag: tags.tag }).from(tags).where(
|
|
2830
|
+
and(
|
|
2831
|
+
eq(tags.vaultId, vaultScopeId),
|
|
2832
|
+
inArray(tags.specId, specIds),
|
|
2833
|
+
eq(tags.inFrontmatter, true)
|
|
2834
|
+
)
|
|
2835
|
+
);
|
|
2836
|
+
for (const r of rows) {
|
|
2837
|
+
const bucket = out.get(r.specId);
|
|
2838
|
+
if (bucket) bucket.push(r.tag);
|
|
2839
|
+
else out.set(r.specId, [r.tag]);
|
|
2840
|
+
}
|
|
2841
|
+
return out;
|
|
2842
|
+
}
|
|
2843
|
+
async function aliasesForMany(db, specIds, vaultScopeId) {
|
|
2844
|
+
const out = /* @__PURE__ */ new Map();
|
|
2845
|
+
if (specIds.length === 0) return out;
|
|
2846
|
+
const rows = await db.select({ specId: aliases.specId, alias: aliases.alias }).from(aliases).where(
|
|
2847
|
+
and(eq(aliases.vaultId, vaultScopeId), inArray(aliases.specId, specIds))
|
|
2848
|
+
);
|
|
2849
|
+
for (const r of rows) {
|
|
2850
|
+
const bucket = out.get(r.specId);
|
|
2851
|
+
if (bucket) bucket.push(r.alias);
|
|
2852
|
+
else out.set(r.specId, [r.alias]);
|
|
2853
|
+
}
|
|
2854
|
+
return out;
|
|
2855
|
+
}
|
|
2856
|
+
async function extrasForMany(db, specIds, vaultScopeId) {
|
|
2857
|
+
const out = /* @__PURE__ */ new Map();
|
|
2858
|
+
if (specIds.length === 0) return out;
|
|
2859
|
+
const rows = await db.select({
|
|
2860
|
+
specId: frontmatterFields.specId,
|
|
2861
|
+
field: frontmatterFields.field,
|
|
2862
|
+
value: frontmatterFields.value
|
|
2863
|
+
}).from(frontmatterFields).where(
|
|
2864
|
+
and(
|
|
2865
|
+
eq(frontmatterFields.vaultId, vaultScopeId),
|
|
2866
|
+
inArray(frontmatterFields.specId, specIds)
|
|
2867
|
+
)
|
|
2868
|
+
);
|
|
2869
|
+
for (const r of rows) {
|
|
2870
|
+
let bucket = out.get(r.specId);
|
|
2871
|
+
if (!bucket) {
|
|
2872
|
+
bucket = {};
|
|
2873
|
+
out.set(r.specId, bucket);
|
|
2874
|
+
}
|
|
2875
|
+
bucket[r.field] = r.value;
|
|
2876
|
+
}
|
|
2877
|
+
return out;
|
|
2878
|
+
}
|
|
2879
|
+
async function fetchAllSpecBundlesFromNeon(db = getPostgresDb(), vaultScopeId = resolveVaultScopeId()) {
|
|
2880
|
+
const rows = await db.select({
|
|
2881
|
+
id: specs.id,
|
|
2882
|
+
name: specs.name,
|
|
2883
|
+
type: specs.type,
|
|
2884
|
+
status: specs.status,
|
|
2885
|
+
priority: specs.priority,
|
|
2886
|
+
owner: specs.owner,
|
|
2887
|
+
created: specs.created,
|
|
2888
|
+
updated: specs.updated,
|
|
2889
|
+
relPath: specs.relPath,
|
|
2890
|
+
bodyText: specs.bodyText
|
|
2891
|
+
}).from(specs).where(and(eq(specs.vaultId, vaultScopeId), isNull(specs.deletedAt)));
|
|
2892
|
+
if (rows.length === 0) return [];
|
|
2893
|
+
const ids = rows.map((r) => r.id);
|
|
2894
|
+
const [tagsMap, aliasesMap, extrasMap] = await Promise.all([
|
|
2895
|
+
fmTagsForMany(db, ids, vaultScopeId),
|
|
2896
|
+
aliasesForMany(db, ids, vaultScopeId),
|
|
2897
|
+
extrasForMany(db, ids, vaultScopeId)
|
|
2898
|
+
]);
|
|
2899
|
+
return rows.map((row) => ({
|
|
2900
|
+
row,
|
|
2901
|
+
tags: tagsMap.get(row.id) ?? [],
|
|
2902
|
+
aliases: aliasesMap.get(row.id) ?? [],
|
|
2903
|
+
extras: extrasMap.get(row.id) ?? {},
|
|
2904
|
+
bodyText: row.bodyText ?? ""
|
|
2905
|
+
}));
|
|
2906
|
+
}
|
|
2907
|
+
function frontmatterFromNeonBundle(bundle) {
|
|
2908
|
+
return buildFrontmatter(bundle.row, bundle.tags, bundle.aliases, bundle.extras);
|
|
2909
|
+
}
|
|
2910
|
+
var init_spec_read_service = __esm({
|
|
2911
|
+
"spex-console/src/lib/db/repositories/spec-read-service.ts"() {
|
|
2912
|
+
"use strict";
|
|
2913
|
+
init_sql_orm_inert_cli_shim();
|
|
2914
|
+
init_scope();
|
|
2915
|
+
init_postgres_client_cli_shim();
|
|
2916
|
+
init_postgres();
|
|
2917
|
+
init_helpers();
|
|
2918
|
+
}
|
|
2919
|
+
});
|
|
2920
|
+
|
|
2921
|
+
// spex-console/bin/hs-vault.ts
|
|
2922
|
+
import { existsSync as existsSync5, readFileSync as readFileSync6 } from "node:fs";
|
|
2923
|
+
import path6 from "node:path";
|
|
2924
|
+
import process2 from "node:process";
|
|
2925
|
+
import { createInterface } from "node:readline/promises";
|
|
2926
|
+
|
|
2927
|
+
// spex-console/src/lib/cli/control-shared.ts
|
|
2928
|
+
import { readFileSync } from "node:fs";
|
|
2929
|
+
import { homedir } from "node:os";
|
|
2930
|
+
import { join } from "node:path";
|
|
2931
|
+
var EXIT = {
|
|
2932
|
+
/** Success. */
|
|
2933
|
+
OK: 0,
|
|
2934
|
+
/** Usage error — unknown subcommand/flag, or a missing required argument. */
|
|
2935
|
+
USAGE: 1,
|
|
2936
|
+
/** Claim race lost — the spec is already claimed by another agent (409). */
|
|
2937
|
+
CONFLICT: 9,
|
|
2938
|
+
/** Target not found — release of an unheld claim, etc. (404). */
|
|
2939
|
+
NOT_FOUND: 10,
|
|
2940
|
+
/** Invalid argument — bad `--ttl`, unknown `--kind`, unparseable date. */
|
|
2941
|
+
INVALID_ARG: 11,
|
|
2942
|
+
/** SpexConsole unreachable (network error reaching the dev server). */
|
|
2943
|
+
CONSOLE_UNREACHABLE: 12,
|
|
2944
|
+
/** Server failure — a non-OK response that isn't 400/402/404/409. */
|
|
2945
|
+
WRITE_FAILED: 13,
|
|
2946
|
+
/** Neon control plane absent — the endpoint returned 402 (upgrade required). */
|
|
2947
|
+
NEON_REQUIRED: 14,
|
|
2948
|
+
/**
|
|
2949
|
+
* A cross-account `--account <id>` override was refused — the caller is
|
|
2950
|
+
* not a builder/admin (`resolveScopedAccountId`'s `forbidden_account_override`,
|
|
2951
|
+
* [[HS-X3GB-EKLJ-TASK-hs-cli-account-flag-for-cross-account]]). Distinct
|
|
2952
|
+
* from `USAGE`/`INVALID_ARG` — this is an authorization refusal, not a
|
|
2953
|
+
* malformed command.
|
|
2954
|
+
*/
|
|
2955
|
+
FORBIDDEN: 15
|
|
2956
|
+
};
|
|
2957
|
+
function readPairedToken() {
|
|
2958
|
+
try {
|
|
2959
|
+
const raw = readFileSync(join(homedir(), ".heyspex", "credentials.yaml"), "utf-8");
|
|
2960
|
+
const match = raw.match(/\b(hsmcp_[A-Za-z0-9_-]+|hscmp_[A-Za-z0-9_-]+)\b/);
|
|
2961
|
+
return match?.[1];
|
|
2962
|
+
} catch {
|
|
2963
|
+
return void 0;
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
// spex-console/src/lib/cli/defer.ts
|
|
2968
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
2969
|
+
import os from "node:os";
|
|
2970
|
+
import path from "node:path";
|
|
2971
|
+
|
|
2972
|
+
// scripts/lib/console-url-resolver.mjs
|
|
2973
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
2974
|
+
import { homedir as homedir2 } from "node:os";
|
|
2975
|
+
import { join as join2 } from "node:path";
|
|
2976
|
+
var DEFAULT_CONSOLE_URL = "https://app.heyspex.com";
|
|
2977
|
+
function trimTrailingSlash(url) {
|
|
2978
|
+
return url.replace(/\/+$/, "");
|
|
2979
|
+
}
|
|
2980
|
+
function readCredentialsConsoleUrl({ homeDir = homedir2() } = {}) {
|
|
2981
|
+
try {
|
|
2982
|
+
const raw = readFileSync2(join2(homeDir, ".heyspex", "credentials.yaml"), "utf8");
|
|
2983
|
+
const match = raw.match(/^console_url:\s*["']?([^"'\r\n#]+)/m);
|
|
2984
|
+
const value = match?.[1]?.trim();
|
|
2985
|
+
return value && value.length > 0 ? trimTrailingSlash(value) : null;
|
|
2986
|
+
} catch {
|
|
2987
|
+
return null;
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
function readDiscoveryFileConsoleUrl(repoRoot) {
|
|
2991
|
+
if (!repoRoot) return null;
|
|
2992
|
+
for (const name of [
|
|
2993
|
+
"console-url",
|
|
2994
|
+
"cockpit-url"
|
|
2995
|
+
/* legacy-cockpit */
|
|
2996
|
+
]) {
|
|
2997
|
+
try {
|
|
2998
|
+
const discovery = readFileSync2(join2(repoRoot, ".heyspex", name), "utf8").trim();
|
|
2999
|
+
if (discovery.length > 0) return trimTrailingSlash(discovery);
|
|
3000
|
+
} catch {
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
return null;
|
|
3004
|
+
}
|
|
3005
|
+
var ConsoleUrlUnresolvedError = class extends Error {
|
|
3006
|
+
constructor() {
|
|
3007
|
+
super(
|
|
3008
|
+
`resolveConsoleUrl: no HEYSPEX_CONSOLE_URL/HEYSPEX_COCKPIT_URL env, no console_url: in ~/.heyspex/credentials.yaml, and no .heyspex/console-url discovery file \u2014 refusing to silently fall back to the ${DEFAULT_CONSOLE_URL} default in strict mode.`
|
|
3009
|
+
);
|
|
3010
|
+
this.name = "ConsoleUrlUnresolvedError";
|
|
3011
|
+
}
|
|
3012
|
+
};
|
|
3013
|
+
function resolveConsoleUrl({
|
|
3014
|
+
repoRoot,
|
|
3015
|
+
env = process.env,
|
|
3016
|
+
homeDir = homedir2(),
|
|
3017
|
+
strict = false
|
|
3018
|
+
} = {}) {
|
|
3019
|
+
const envUrl = (env.HEYSPEX_CONSOLE_URL ?? env.HEYSPEX_COCKPIT_URL)?.trim();
|
|
3020
|
+
if (envUrl) return trimTrailingSlash(envUrl);
|
|
3021
|
+
const credentialsUrl = readCredentialsConsoleUrl({ homeDir });
|
|
3022
|
+
if (credentialsUrl) return credentialsUrl;
|
|
3023
|
+
const discoveryUrl = readDiscoveryFileConsoleUrl(repoRoot);
|
|
3024
|
+
if (discoveryUrl) return discoveryUrl;
|
|
3025
|
+
if (strict) throw new ConsoleUrlUnresolvedError();
|
|
3026
|
+
return DEFAULT_CONSOLE_URL;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
// spex-console/src/lib/cli/defer.ts
|
|
3030
|
+
function resolveSpexConsoleUrl(vaultRoot, readDiscovery, opts = {}) {
|
|
3031
|
+
const env = opts.env ?? process.env;
|
|
3032
|
+
const raw = (env.HEYSPEX_CONSOLE_URL ?? env.HEYSPEX_COCKPIT_URL)?.trim();
|
|
3033
|
+
if (raw && raw.length > 0) return raw.replace(/\/+$/, "");
|
|
3034
|
+
try {
|
|
3035
|
+
const homeDir = opts.homeDir ?? os.homedir();
|
|
3036
|
+
const readFile2 = opts.readFile ?? ((p) => readFileSync3(p, "utf-8"));
|
|
3037
|
+
const credentials = readFile2(path.join(homeDir, ".heyspex", "credentials.yaml"));
|
|
3038
|
+
const match = credentials.match(/^console_url:\s*["']?([^"'\r\n#]+)/m);
|
|
3039
|
+
const value = match?.[1]?.trim();
|
|
3040
|
+
if (value && value.length > 0) return value.replace(/\/+$/, "");
|
|
3041
|
+
} catch {
|
|
3042
|
+
}
|
|
3043
|
+
for (const name of ["console-url", "cockpit-url"]) {
|
|
3044
|
+
try {
|
|
3045
|
+
const discovery = readDiscovery(`${vaultRoot}/.heyspex/${name}`);
|
|
3046
|
+
if (discovery && discovery.trim().length > 0) {
|
|
3047
|
+
return discovery.trim().replace(/\/+$/, "");
|
|
3048
|
+
}
|
|
3049
|
+
} catch {
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
return DEFAULT_CONSOLE_URL;
|
|
3053
|
+
}
|
|
3054
|
+
var HELP = [
|
|
3055
|
+
"Usage:",
|
|
3056
|
+
" hs defer <spec-id> [--until <when>] [--json] [--no-daemon]",
|
|
3057
|
+
" hs undefer <spec-id> [--json] [--no-daemon]",
|
|
3058
|
+
"",
|
|
3059
|
+
"Time-shift a spec via its `defer_until` frontmatter field. A deferred spec is",
|
|
3060
|
+
"scheduler-invisible until the date passes, then auto-revives.",
|
|
3061
|
+
"",
|
|
3062
|
+
"Arguments:",
|
|
3063
|
+
" <spec-id> The spec to defer/undefer (e.g. HS-TASK-EWL5).",
|
|
3064
|
+
"",
|
|
3065
|
+
"Flags:",
|
|
3066
|
+
" --until <when> ISO date, or relative offset +Nd / +Nw / +Nm / +Ny.",
|
|
3067
|
+
" Defaults to +1y (matches the console Defer button).",
|
|
3068
|
+
" Ignored by `undefer` (which always clears the field).",
|
|
3069
|
+
" --json Emit a machine-readable JSON result.",
|
|
3070
|
+
" --no-daemon Write straight to the vault (no console needed; CI path).",
|
|
3071
|
+
" --help, -h Show this help.",
|
|
3072
|
+
"",
|
|
3073
|
+
"Exit codes: 0 ok \xB7 1 usage \xB7 10 not-found \xB7 11 bad-arg \xB7 12 console-unreachable \xB7 13 write-failed"
|
|
3074
|
+
].join("\n");
|
|
3075
|
+
|
|
3076
|
+
// spex-console/src/lib/cli/vault.ts
|
|
3077
|
+
init_account_pointer();
|
|
3078
|
+
|
|
3079
|
+
// dist/cli/._admin-scope-inert.cli-shim.mjs
|
|
3080
|
+
async function resolveScopedAccountId(callerAccountId, requestedAccountId) {
|
|
3081
|
+
const target = requestedAccountId == null ? "" : String(requestedAccountId).trim();
|
|
3082
|
+
if (!target || target === callerAccountId) {
|
|
3083
|
+
return { ok: true, accountId: callerAccountId };
|
|
3084
|
+
}
|
|
3085
|
+
return {
|
|
3086
|
+
ok: false,
|
|
3087
|
+
code: "forbidden_account_override",
|
|
3088
|
+
message: "Only a builder/admin may act on another account's workspaces or vaults."
|
|
3089
|
+
};
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
// spex-console/src/lib/vault-registry/archive.ts
|
|
3093
|
+
init_vault_workspace_shared();
|
|
3094
|
+
|
|
3095
|
+
// spex-console/src/lib/vault-registry/active-repoint.ts
|
|
3096
|
+
init_account_pointer();
|
|
3097
|
+
init_registry_file();
|
|
3098
|
+
function pointerDeps(deps) {
|
|
3099
|
+
return deps.accountPointer ?? { registryOpts: deps.registryOpts };
|
|
3100
|
+
}
|
|
3101
|
+
async function repointActiveVaultOnRemoval(accountId2, removedVaultId, removedVaultWorkspaceId, deps = {}) {
|
|
3102
|
+
const pDeps = pointerDeps(deps);
|
|
3103
|
+
const activeId = await getActiveVault(accountId2, pDeps);
|
|
3104
|
+
if (activeId !== removedVaultId) {
|
|
3105
|
+
return { repointed: false, newActiveId: activeId };
|
|
3106
|
+
}
|
|
3107
|
+
const registry = loadVaultRegistry(deps.registryOpts);
|
|
3108
|
+
const candidate = registry.vaults.find(
|
|
3109
|
+
(v) => v.id !== removedVaultId && v.workspace_id === removedVaultWorkspaceId && !v.archived
|
|
3110
|
+
);
|
|
3111
|
+
if (!candidate) {
|
|
3112
|
+
await clearActiveVault(accountId2, pDeps);
|
|
3113
|
+
return { repointed: true, newActiveId: null };
|
|
3114
|
+
}
|
|
3115
|
+
await setActiveVault(accountId2, candidate.id, pDeps);
|
|
3116
|
+
return { repointed: true, newActiveId: candidate.id };
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
// spex-console/src/lib/vault-registry/archive.ts
|
|
3120
|
+
init_registry_file();
|
|
3121
|
+
function vaultNotFound(id) {
|
|
3122
|
+
return { ok: false, code: "vault_not_found", message: `vault "${id}" not found` };
|
|
3123
|
+
}
|
|
3124
|
+
async function setVaultArchivedState(input, archived, deps = {}) {
|
|
3125
|
+
const registry = loadVaultRegistry(deps.registryOpts);
|
|
3126
|
+
const existing = registry.vaults.find((v) => v.id === input.id);
|
|
3127
|
+
if (!existing) return vaultNotFound(input.id);
|
|
3128
|
+
const neonResult = await setVaultArchivedNeon(
|
|
3129
|
+
input.id,
|
|
3130
|
+
archived,
|
|
3131
|
+
input.accountId,
|
|
3132
|
+
deps.neon ?? {}
|
|
3133
|
+
);
|
|
3134
|
+
if (neonResult === "not_found") return vaultNotFound(input.id);
|
|
3135
|
+
if (archived) {
|
|
3136
|
+
await repointActiveVaultOnRemoval(input.accountId, input.id, existing.workspace_id, {
|
|
3137
|
+
registryOpts: deps.registryOpts,
|
|
3138
|
+
accountPointer: { ...deps.neon, registryOpts: deps.registryOpts }
|
|
3139
|
+
});
|
|
3140
|
+
}
|
|
3141
|
+
registerVault({ ...existing, archived }, deps.registryOpts);
|
|
3142
|
+
return {
|
|
3143
|
+
ok: true,
|
|
3144
|
+
vault: {
|
|
3145
|
+
id: input.id,
|
|
3146
|
+
archived,
|
|
3147
|
+
neon_written: neonResult === "updated",
|
|
3148
|
+
registry_written: true
|
|
3149
|
+
}
|
|
3150
|
+
};
|
|
3151
|
+
}
|
|
3152
|
+
async function archiveVault(input, deps = {}) {
|
|
3153
|
+
return setVaultArchivedState(input, true, deps);
|
|
3154
|
+
}
|
|
3155
|
+
async function restoreVault(input, deps = {}) {
|
|
3156
|
+
return setVaultArchivedState(input, false, deps);
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
// spex-console/src/lib/vault-registry/close-not-destroy.ts
|
|
3160
|
+
var CLOSE_NOT_DESTROY_STATEMENT = "This only removes it from HeySpex. The repo files on disk are not deleted.";
|
|
3161
|
+
var KIND_LABEL = {
|
|
3162
|
+
workspace: "workspace",
|
|
3163
|
+
vault: "vault"
|
|
3164
|
+
};
|
|
3165
|
+
function buildCloseNotDestroyConfirm(kind, verb, displayName) {
|
|
3166
|
+
const kindLabel = KIND_LABEL[kind];
|
|
3167
|
+
const name = displayName.trim() || kindLabel;
|
|
3168
|
+
if (verb === "archive") {
|
|
3169
|
+
return {
|
|
3170
|
+
title: `Archive "${name}"?`,
|
|
3171
|
+
message: `Archiving hides the ${kindLabel} from your active list. ${CLOSE_NOT_DESTROY_STATEMENT} You can restore it any time.`,
|
|
3172
|
+
confirmLabel: "Archive",
|
|
3173
|
+
cancelLabel: "Cancel",
|
|
3174
|
+
removesRegistryRow: false
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
return {
|
|
3178
|
+
title: `Remove "${name}" from HeySpex?`,
|
|
3179
|
+
message: `${CLOSE_NOT_DESTROY_STATEMENT} It will need to be re-registered to appear here again.`,
|
|
3180
|
+
confirmLabel: "Remove from HeySpex",
|
|
3181
|
+
cancelLabel: "Cancel",
|
|
3182
|
+
removesRegistryRow: true
|
|
3183
|
+
};
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
// spex-console/src/lib/vault-registry/list.ts
|
|
3187
|
+
init_vault_workspace_shared();
|
|
3188
|
+
init_account_pointer();
|
|
3189
|
+
init_registry_file();
|
|
3190
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
3191
|
+
async function defaultNeonReadTierEnabled2() {
|
|
3192
|
+
const { isNeonReadTierEnabled: isNeonReadTierEnabled2 } = await Promise.resolve().then(() => (init_spec_read_service(), spec_read_service_exports));
|
|
3193
|
+
return isNeonReadTierEnabled2();
|
|
3194
|
+
}
|
|
3195
|
+
function pointerDeps2(deps) {
|
|
3196
|
+
return deps.accountPointer ?? { registryOpts: deps.registryOpts };
|
|
3197
|
+
}
|
|
3198
|
+
function localVaultRows(deps) {
|
|
3199
|
+
return loadVaultRegistry(deps.registryOpts).vaults;
|
|
3200
|
+
}
|
|
3201
|
+
async function listVaultsForWorkspace(accountId2, workspaceId, deps = {}) {
|
|
3202
|
+
const pDeps = pointerDeps2(deps);
|
|
3203
|
+
const neonRows = await (deps.neonListVaults ?? listVaultsNeon)(accountId2, {
|
|
3204
|
+
db: pDeps.db,
|
|
3205
|
+
neonReachable: pDeps.neonReachable
|
|
3206
|
+
});
|
|
3207
|
+
const allRows = neonRows ?? (deps.allowLocalRegistryFallback ? localVaultRows(deps) : []);
|
|
3208
|
+
const activeId = await getActiveVault(accountId2, pDeps);
|
|
3209
|
+
const rows = deps.includeArchived ? allRows : allRows.filter((v) => !v.archived);
|
|
3210
|
+
const scoped = workspaceId ? rows.filter((v) => v.workspace_id === workspaceId) : rows;
|
|
3211
|
+
const pathExists = deps.pathExists ?? existsSync4;
|
|
3212
|
+
const anyPathUnusable = scoped.some((v) => !v.repo_root_abs || !pathExists(v.repo_root_abs));
|
|
3213
|
+
const neonBackedServeable = anyPathUnusable && neonRows !== null ? await (deps.neonReadTierEnabled ?? defaultNeonReadTierEnabled2)() : false;
|
|
3214
|
+
return {
|
|
3215
|
+
vaults: scoped.map((v) => ({
|
|
3216
|
+
...v,
|
|
3217
|
+
active: v.id === activeId,
|
|
3218
|
+
...serveability(v, pathExists, neonBackedServeable)
|
|
3219
|
+
})),
|
|
3220
|
+
active_vault_id: activeId,
|
|
3221
|
+
workspace_id: workspaceId ?? null
|
|
3222
|
+
};
|
|
3223
|
+
}
|
|
3224
|
+
function serveability(v, pathExists, neonBackedServeable) {
|
|
3225
|
+
if (neonBackedServeable) return { serveable: true };
|
|
3226
|
+
if (!v.repo_root_abs) {
|
|
3227
|
+
return {
|
|
3228
|
+
serveable: false,
|
|
3229
|
+
unserveable_reason: "No local path registered on this machine."
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
if (!pathExists(v.repo_root_abs)) {
|
|
3233
|
+
return {
|
|
3234
|
+
serveable: false,
|
|
3235
|
+
unserveable_reason: `Local path not found on this machine: ${v.repo_root_abs}`
|
|
3236
|
+
};
|
|
3237
|
+
}
|
|
3238
|
+
return { serveable: true };
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
// spex-console/src/lib/cli/vault.ts
|
|
3242
|
+
init_registry_file();
|
|
3243
|
+
|
|
3244
|
+
// spex-console/src/lib/vault-registry/vault-manage.ts
|
|
3245
|
+
init_vault_workspace_shared();
|
|
3246
|
+
init_registry_file();
|
|
3247
|
+
var MAX_DISPLAY_NAME_LEN = 60;
|
|
3248
|
+
function isValidId2(id) {
|
|
3249
|
+
if (typeof id !== "string" || id.trim() === "") return false;
|
|
3250
|
+
if (id.includes("/") || id.includes("\\")) return false;
|
|
3251
|
+
if (id === "." || id === "..") return false;
|
|
3252
|
+
return true;
|
|
3253
|
+
}
|
|
3254
|
+
function notFound(id) {
|
|
3255
|
+
return { ok: false, code: "vault_not_found", message: `vault "${id}" not found` };
|
|
3256
|
+
}
|
|
3257
|
+
async function renameVault(input, deps = {}) {
|
|
3258
|
+
const trimmedName = input.displayName.trim();
|
|
3259
|
+
if (trimmedName.length < 1 || trimmedName.length > MAX_DISPLAY_NAME_LEN) {
|
|
3260
|
+
return {
|
|
3261
|
+
ok: false,
|
|
3262
|
+
code: "invalid_display_name",
|
|
3263
|
+
message: `display name must be 1-${MAX_DISPLAY_NAME_LEN} characters (trimmed)`
|
|
3264
|
+
};
|
|
3265
|
+
}
|
|
3266
|
+
const registry = loadVaultRegistry(deps.registryOpts);
|
|
3267
|
+
const existing = registry.vaults.find((v) => v.id === input.id);
|
|
3268
|
+
if (!existing) return notFound(input.id);
|
|
3269
|
+
const neonResult = await renameVaultNeon(input.id, trimmedName, input.accountId, deps.neon ?? {});
|
|
3270
|
+
if (neonResult === "not_found") return notFound(input.id);
|
|
3271
|
+
registerVault({ ...existing, display_name: trimmedName }, deps.registryOpts);
|
|
3272
|
+
return {
|
|
3273
|
+
ok: true,
|
|
3274
|
+
vault: {
|
|
3275
|
+
id: input.id,
|
|
3276
|
+
display_name: trimmedName,
|
|
3277
|
+
neon_written: neonResult === "updated",
|
|
3278
|
+
registry_written: true
|
|
3279
|
+
}
|
|
3280
|
+
};
|
|
3281
|
+
}
|
|
3282
|
+
async function moveVault(input, deps = {}) {
|
|
3283
|
+
if (!isValidId2(input.workspaceId)) {
|
|
3284
|
+
return {
|
|
3285
|
+
ok: false,
|
|
3286
|
+
code: "invalid_workspace_id",
|
|
3287
|
+
message: `invalid workspace id "${String(input.workspaceId)}" -- must be non-empty with no path separators`
|
|
3288
|
+
};
|
|
3289
|
+
}
|
|
3290
|
+
const registry = loadVaultRegistry(deps.registryOpts);
|
|
3291
|
+
const existing = registry.vaults.find((v) => v.id === input.id);
|
|
3292
|
+
if (!existing) return notFound(input.id);
|
|
3293
|
+
const neonResult = await moveVaultNeon(
|
|
3294
|
+
input.id,
|
|
3295
|
+
input.workspaceId,
|
|
3296
|
+
input.accountId,
|
|
3297
|
+
deps.neon ?? {}
|
|
3298
|
+
);
|
|
3299
|
+
if (neonResult === "not_found") return notFound(input.id);
|
|
3300
|
+
registerVault({ ...existing, workspace_id: input.workspaceId }, deps.registryOpts);
|
|
3301
|
+
return {
|
|
3302
|
+
ok: true,
|
|
3303
|
+
vault: {
|
|
3304
|
+
id: input.id,
|
|
3305
|
+
workspace_id: input.workspaceId,
|
|
3306
|
+
neon_written: neonResult === "updated",
|
|
3307
|
+
registry_written: true
|
|
3308
|
+
}
|
|
3309
|
+
};
|
|
3310
|
+
}
|
|
3311
|
+
async function unregisterVault(input, deps = {}) {
|
|
3312
|
+
const registry = loadVaultRegistry(deps.registryOpts);
|
|
3313
|
+
const existing = registry.vaults.find((v) => v.id === input.id);
|
|
3314
|
+
if (!existing) return notFound(input.id);
|
|
3315
|
+
const neonResult = await deleteVaultNeon(input.id, input.accountId, deps.neon ?? {});
|
|
3316
|
+
if (neonResult === "not_found") return notFound(input.id);
|
|
3317
|
+
await repointActiveVaultOnRemoval(input.accountId, input.id, existing.workspace_id, {
|
|
3318
|
+
registryOpts: deps.registryOpts,
|
|
3319
|
+
accountPointer: { ...deps.neon, registryOpts: deps.registryOpts }
|
|
3320
|
+
});
|
|
3321
|
+
removeVault(input.id, deps.registryOpts);
|
|
3322
|
+
return {
|
|
3323
|
+
ok: true,
|
|
3324
|
+
vault: {
|
|
3325
|
+
id: input.id,
|
|
3326
|
+
neon_written: neonResult === "deleted",
|
|
3327
|
+
registry_written: true
|
|
3328
|
+
}
|
|
3329
|
+
};
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
// spex-console/src/lib/cli/vault.ts
|
|
3333
|
+
init_vault_resolve();
|
|
3334
|
+
|
|
3335
|
+
// spex-console/src/lib/cli/init.ts
|
|
3336
|
+
import { randomUUID } from "node:crypto";
|
|
3337
|
+
var DEFAULT_CONSOLE_URL2 = resolveConsoleUrl();
|
|
3338
|
+
function parseVaultId(content) {
|
|
3339
|
+
const m = content.match(/^vault_id:\s*(.+?)\s*$/m);
|
|
3340
|
+
if (!m) return null;
|
|
3341
|
+
const raw = m[1].trim();
|
|
3342
|
+
const unquoted = raw.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
|
|
3343
|
+
return unquoted.length > 0 ? unquoted : null;
|
|
3344
|
+
}
|
|
3345
|
+
var HELP2 = [
|
|
3346
|
+
"Usage:",
|
|
3347
|
+
" hs init [--console-url <url>] [--vault-root <path>] [--force] [--json]",
|
|
3348
|
+
" hs init --customer-vault <dir> [--console-url <url>] [--principles <all|none|a,b>]",
|
|
3349
|
+
" [--vault-name <name>] [--no-git] [--json]",
|
|
3350
|
+
"",
|
|
3351
|
+
"Write the top-level heyspex.yaml vault marker (schema_version, console_url,",
|
|
3352
|
+
"vault_root, vault_id) \u2014 the committed file the console / web companion read",
|
|
3353
|
+
"to bootstrap a vault. Idempotent: an existing marker is left untouched unless",
|
|
3354
|
+
"--force, EXCEPT a missing vault_id is backfilled in place either way (backward",
|
|
3355
|
+
"compatible \u2014 no schema_version bump). vault_id is reused exactly when already",
|
|
3356
|
+
"present; a fresh one is minted only when absent.",
|
|
3357
|
+
"Does not modify .heyspex/config.yaml or its templates (ADR-0009).",
|
|
3358
|
+
"",
|
|
3359
|
+
"--customer-vault <dir> scaffolds a REAL customer vault at <dir> instead: the",
|
|
3360
|
+
"specs/ folder tree, heyspex.yaml, and every row of the template shipping",
|
|
3361
|
+
"manifest (spex-console/src/lib/cli/template-manifest.ts) \u2014 universal docs",
|
|
3362
|
+
"copied verbatim, template-shape docs written as author-prompt skeletons \u2014",
|
|
3363
|
+
"plus a generated CLAUDE.md, the kept-principles index, and (unless --no-git)",
|
|
3364
|
+
"one initial `git init` + commit pinning the template version. Refuses a",
|
|
3365
|
+
"non-empty <dir> with a plain error. See init.customer-vault.ts.",
|
|
3366
|
+
"",
|
|
3367
|
+
"Flags:",
|
|
3368
|
+
" --console-url <url> console_url value (default: $HEYSPEX_CONSOLE_URL or",
|
|
3369
|
+
" https://console.heyspex.local).",
|
|
3370
|
+
" --vault-root <path> vault_root value (default: specs/).",
|
|
3371
|
+
" --customer-vault <dir> Scaffold a new customer vault at <dir> (see above).",
|
|
3372
|
+
" --principles <all|none|a,b> Which universal principle docs to ship",
|
|
3373
|
+
" (--customer-vault only). Omitted + a TTY: interactive",
|
|
3374
|
+
" keep/skip prompt per principle. Omitted + no TTY: all.",
|
|
3375
|
+
" --vault-name <name> Display name for the scaffolded vault (--customer-vault",
|
|
3376
|
+
" only; default: <dir>'s basename).",
|
|
3377
|
+
" --no-git Skip git init + the initial commit (--customer-vault only).",
|
|
3378
|
+
" --force Overwrite an existing heyspex.yaml.",
|
|
3379
|
+
" --json Emit a machine-readable JSON result.",
|
|
3380
|
+
" --help, -h Show this help.",
|
|
3381
|
+
"",
|
|
3382
|
+
"Exit codes: 0 ok \xB7 1 usage \xB7 11 bad-arg \xB7 13 write-failed"
|
|
3383
|
+
].join("\n");
|
|
3384
|
+
|
|
3385
|
+
// spex-console/src/lib/cli/install-conflicts.ts
|
|
3386
|
+
var INSTALL_CONFLICT_REGISTRY = [
|
|
3387
|
+
{
|
|
3388
|
+
id: "beads-directory",
|
|
3389
|
+
label: "Beads (`.beads/`)",
|
|
3390
|
+
severity: "blocker",
|
|
3391
|
+
why: "Beads and HeySpex both claim to be the agent queue; dual SSOT causes hook + status drift.",
|
|
3392
|
+
remediation: "Remove `.beads/` and retire bd hooks (`hs install` documents manual steps; future: `hs install --remediate beads`)."
|
|
3393
|
+
},
|
|
3394
|
+
{
|
|
3395
|
+
id: "beads-agents-md",
|
|
3396
|
+
label: "Beads block in AGENTS.md",
|
|
3397
|
+
severity: "warning",
|
|
3398
|
+
why: "Agents still receive `bd prime` / `bd ready` instructions alongside HeySpex.",
|
|
3399
|
+
remediation: "Replace the `<!-- BEGIN BEADS INTEGRATION -->` block with HeySpex agent rules."
|
|
3400
|
+
},
|
|
3401
|
+
{
|
|
3402
|
+
id: "beads-claude-md",
|
|
3403
|
+
label: "Beads block in CLAUDE.md",
|
|
3404
|
+
severity: "warning",
|
|
3405
|
+
why: "Claude Code session hooks and close-out still reference beads.",
|
|
3406
|
+
remediation: "Replace beads integration with the HeySpex vault section."
|
|
3407
|
+
},
|
|
3408
|
+
{
|
|
3409
|
+
id: "beads-claude-hooks",
|
|
3410
|
+
label: "Claude Code `bd prime` hooks",
|
|
3411
|
+
severity: "blocker",
|
|
3412
|
+
why: "SessionStart runs `bd prime` every turn \u2014 fights HeySpex MCP recall/on-deck.",
|
|
3413
|
+
remediation: "Clear `.claude/settings.json` hooks or remove `bd prime` entries."
|
|
3414
|
+
},
|
|
3415
|
+
{
|
|
3416
|
+
id: "workflow-pm",
|
|
3417
|
+
label: "workflow-pm / wfpm (beads PM dashboard)",
|
|
3418
|
+
severity: "warning",
|
|
3419
|
+
why: "PM scripts assume `bd` as queue truth.",
|
|
3420
|
+
remediation: "Remove `workflow-pm` devDependency and `pm:*` npm scripts."
|
|
3421
|
+
},
|
|
3422
|
+
{
|
|
3423
|
+
id: "parallel-issue-tracker",
|
|
3424
|
+
label: "Alternate markdown issue tracker under `issues/` or `.github/ISSUE_TEMPLATE` only",
|
|
3425
|
+
severity: "warning",
|
|
3426
|
+
why: "Agents may file work outside `specs/` if instructions mention GitHub Issues as primary.",
|
|
3427
|
+
remediation: "Point agent docs at `specs/` only; keep GitHub Issues optional for humans."
|
|
3428
|
+
}
|
|
3429
|
+
];
|
|
3430
|
+
function includesAny(haystack, needles) {
|
|
3431
|
+
return needles.some((n) => haystack.includes(n));
|
|
3432
|
+
}
|
|
3433
|
+
function scanInstallConflicts(targetDir, deps) {
|
|
3434
|
+
const hits = [];
|
|
3435
|
+
const rule = (id) => INSTALL_CONFLICT_REGISTRY.find((r) => r.id === id);
|
|
3436
|
+
const beadsDir = deps.joinPath(targetDir, ".beads");
|
|
3437
|
+
if (deps.fileExists(beadsDir)) {
|
|
3438
|
+
const r = rule("beads-directory");
|
|
3439
|
+
hits.push({ ...r, signal: beadsDir });
|
|
3440
|
+
}
|
|
3441
|
+
const agentsMd = deps.joinPath(targetDir, "AGENTS.md");
|
|
3442
|
+
const agentsText = deps.readFile(agentsMd);
|
|
3443
|
+
if (agentsText && includesAny(agentsText, [
|
|
3444
|
+
"BEGIN BEADS INTEGRATION",
|
|
3445
|
+
"Use `bd` for ALL task tracking",
|
|
3446
|
+
"Run `bd prime`"
|
|
3447
|
+
])) {
|
|
3448
|
+
const r = rule("beads-agents-md");
|
|
3449
|
+
hits.push({ ...r, signal: agentsMd });
|
|
3450
|
+
}
|
|
3451
|
+
const claudeMd = deps.joinPath(targetDir, "CLAUDE.md");
|
|
3452
|
+
const claudeText = deps.readFile(claudeMd);
|
|
3453
|
+
if (claudeText && includesAny(claudeText, ["BEGIN BEADS INTEGRATION", "bd prime", "bd dolt push"])) {
|
|
3454
|
+
const r = rule("beads-claude-md");
|
|
3455
|
+
hits.push({ ...r, signal: claudeMd });
|
|
3456
|
+
}
|
|
3457
|
+
const claudeSettings = deps.joinPath(targetDir, ".claude", "settings.json");
|
|
3458
|
+
const settingsText = deps.readFile(claudeSettings);
|
|
3459
|
+
if (settingsText && settingsText.includes("bd prime")) {
|
|
3460
|
+
const r = rule("beads-claude-hooks");
|
|
3461
|
+
hits.push({ ...r, signal: claudeSettings });
|
|
3462
|
+
}
|
|
3463
|
+
const pkgJson = deps.joinPath(targetDir, "package.json");
|
|
3464
|
+
const pkgText = deps.readFile(pkgJson);
|
|
3465
|
+
if (pkgText && includesAny(pkgText, ["workflow-pm", '"wfpm"'])) {
|
|
3466
|
+
const r = rule("workflow-pm");
|
|
3467
|
+
hits.push({ ...r, signal: pkgJson });
|
|
3468
|
+
}
|
|
3469
|
+
return hits;
|
|
3470
|
+
}
|
|
3471
|
+
function formatConflictReport(hits) {
|
|
3472
|
+
if (hits.length === 0) {
|
|
3473
|
+
return "Conflict scan: no known tooling conflicts detected.";
|
|
3474
|
+
}
|
|
3475
|
+
const lines = [
|
|
3476
|
+
`Conflict scan: ${hits.length} known conflict(s) detected \u2014 resolve before relying on HeySpex as the only queue.`,
|
|
3477
|
+
""
|
|
3478
|
+
];
|
|
3479
|
+
for (const hit of hits) {
|
|
3480
|
+
lines.push(
|
|
3481
|
+
`[${hit.severity.toUpperCase()}] ${hit.label} (${hit.id})`,
|
|
3482
|
+
` Signal: ${hit.signal}`,
|
|
3483
|
+
` Why: ${hit.why}`,
|
|
3484
|
+
` Fix: ${hit.remediation}`,
|
|
3485
|
+
""
|
|
3486
|
+
);
|
|
3487
|
+
}
|
|
3488
|
+
lines.push(
|
|
3489
|
+
"Registry SSOT: specs/protocols/customer-vault-local-install-protocol.md",
|
|
3490
|
+
"Future: `hs install --remediate <id>` for safe auto-fix paths."
|
|
3491
|
+
);
|
|
3492
|
+
return lines.join("\n");
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
// spex-console/src/lib/cli/vault.ts
|
|
3496
|
+
init_vault_workspace_shared();
|
|
3497
|
+
function parseVaultArgs(argv) {
|
|
3498
|
+
const subRaw = argv[0];
|
|
3499
|
+
if (subRaw === "--help" || subRaw === "-h" || subRaw === void 0) {
|
|
3500
|
+
return { help: true };
|
|
3501
|
+
}
|
|
3502
|
+
const KNOWN_SUBCOMMANDS = [
|
|
3503
|
+
"list",
|
|
3504
|
+
"register",
|
|
3505
|
+
"switch",
|
|
3506
|
+
"active",
|
|
3507
|
+
"rename",
|
|
3508
|
+
"move",
|
|
3509
|
+
"unregister",
|
|
3510
|
+
"archive",
|
|
3511
|
+
"restore"
|
|
3512
|
+
];
|
|
3513
|
+
if (!KNOWN_SUBCOMMANDS.includes(subRaw)) {
|
|
3514
|
+
return { error: { code: EXIT.USAGE, message: `unknown subcommand: ${subRaw}` } };
|
|
3515
|
+
}
|
|
3516
|
+
let json = false;
|
|
3517
|
+
let yes = false;
|
|
3518
|
+
let pathArg;
|
|
3519
|
+
let id;
|
|
3520
|
+
let name;
|
|
3521
|
+
let workspaceId;
|
|
3522
|
+
let account;
|
|
3523
|
+
let positional;
|
|
3524
|
+
for (let i = 1; i < argv.length; i++) {
|
|
3525
|
+
const arg = argv[i];
|
|
3526
|
+
if (arg === "--help" || arg === "-h") return { help: true };
|
|
3527
|
+
else if (arg === "--json") json = true;
|
|
3528
|
+
else if (arg === "--yes" || arg === "-y") yes = true;
|
|
3529
|
+
else if (arg === "--path" || arg.startsWith("--path=")) {
|
|
3530
|
+
const v = arg === "--path" ? argv[++i] : arg.slice("--path=".length);
|
|
3531
|
+
if (v === void 0 || v.trim() === "") {
|
|
3532
|
+
return { error: { code: EXIT.INVALID_ARG, message: "--path requires a value" } };
|
|
3533
|
+
}
|
|
3534
|
+
pathArg = v;
|
|
3535
|
+
} else if (arg === "--id" || arg.startsWith("--id=")) {
|
|
3536
|
+
const v = arg === "--id" ? argv[++i] : arg.slice("--id=".length);
|
|
3537
|
+
if (v === void 0 || v.trim() === "") {
|
|
3538
|
+
return { error: { code: EXIT.INVALID_ARG, message: "--id requires a value" } };
|
|
3539
|
+
}
|
|
3540
|
+
id = v;
|
|
3541
|
+
} else if (arg === "--name" || arg.startsWith("--name=")) {
|
|
3542
|
+
const v = arg === "--name" ? argv[++i] : arg.slice("--name=".length);
|
|
3543
|
+
if (v === void 0 || v.trim() === "") {
|
|
3544
|
+
return { error: { code: EXIT.INVALID_ARG, message: "--name requires a value" } };
|
|
3545
|
+
}
|
|
3546
|
+
name = v;
|
|
3547
|
+
} else if (arg === "--workspace" || arg.startsWith("--workspace=")) {
|
|
3548
|
+
const v = arg === "--workspace" ? argv[++i] : arg.slice("--workspace=".length);
|
|
3549
|
+
if (v === void 0 || v.trim() === "") {
|
|
3550
|
+
return { error: { code: EXIT.INVALID_ARG, message: "--workspace requires a value" } };
|
|
3551
|
+
}
|
|
3552
|
+
workspaceId = v;
|
|
3553
|
+
} else if (arg === "--account" || arg.startsWith("--account=")) {
|
|
3554
|
+
const v = arg === "--account" ? argv[++i] : arg.slice("--account=".length);
|
|
3555
|
+
if (v === void 0 || v.trim() === "") {
|
|
3556
|
+
return { error: { code: EXIT.INVALID_ARG, message: "--account requires a value" } };
|
|
3557
|
+
}
|
|
3558
|
+
account = v;
|
|
3559
|
+
} else if (arg.startsWith("-")) {
|
|
3560
|
+
return { error: { code: EXIT.USAGE, message: `unknown flag: ${arg}` } };
|
|
3561
|
+
} else if (positional === void 0) {
|
|
3562
|
+
positional = arg;
|
|
3563
|
+
} else {
|
|
3564
|
+
return { error: { code: EXIT.USAGE, message: `unexpected argument: ${arg}` } };
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
if (subRaw === "list") return { args: { sub: "list", json, account } };
|
|
3568
|
+
if (subRaw === "active") return { args: { sub: "active", json } };
|
|
3569
|
+
if (subRaw === "switch") {
|
|
3570
|
+
if (!positional) {
|
|
3571
|
+
return { error: { code: EXIT.USAGE, message: "vault switch requires a vault id" } };
|
|
3572
|
+
}
|
|
3573
|
+
return { args: { sub: "switch", id: positional, json } };
|
|
3574
|
+
}
|
|
3575
|
+
if (subRaw === "rename") {
|
|
3576
|
+
const targetId = id ?? positional;
|
|
3577
|
+
if (!targetId) {
|
|
3578
|
+
return { error: { code: EXIT.USAGE, message: "vault rename requires a vault id" } };
|
|
3579
|
+
}
|
|
3580
|
+
if (!name) {
|
|
3581
|
+
return {
|
|
3582
|
+
error: { code: EXIT.USAGE, message: 'vault rename requires --name "<display name>"' }
|
|
3583
|
+
};
|
|
3584
|
+
}
|
|
3585
|
+
return { args: { sub: "rename", id: targetId, name, json, account } };
|
|
3586
|
+
}
|
|
3587
|
+
if (subRaw === "move") {
|
|
3588
|
+
const targetId = id ?? positional;
|
|
3589
|
+
if (!targetId) {
|
|
3590
|
+
return { error: { code: EXIT.USAGE, message: "vault move requires a vault id" } };
|
|
3591
|
+
}
|
|
3592
|
+
if (!workspaceId) {
|
|
3593
|
+
return { error: { code: EXIT.USAGE, message: "vault move requires --workspace <id>" } };
|
|
3594
|
+
}
|
|
3595
|
+
return { args: { sub: "move", id: targetId, workspaceId, json, account } };
|
|
3596
|
+
}
|
|
3597
|
+
if (subRaw === "unregister") {
|
|
3598
|
+
const targetId = id ?? positional;
|
|
3599
|
+
if (!targetId) {
|
|
3600
|
+
return { error: { code: EXIT.USAGE, message: "vault unregister requires a vault id" } };
|
|
3601
|
+
}
|
|
3602
|
+
return { args: { sub: "unregister", id: targetId, yes, json, account } };
|
|
3603
|
+
}
|
|
3604
|
+
if (subRaw === "archive") {
|
|
3605
|
+
const targetId = id ?? positional;
|
|
3606
|
+
if (!targetId) {
|
|
3607
|
+
return { error: { code: EXIT.USAGE, message: "vault archive requires a vault id" } };
|
|
3608
|
+
}
|
|
3609
|
+
return { args: { sub: "archive", id: targetId, yes, json, account } };
|
|
3610
|
+
}
|
|
3611
|
+
if (subRaw === "restore") {
|
|
3612
|
+
const targetId = id ?? positional;
|
|
3613
|
+
if (!targetId) {
|
|
3614
|
+
return { error: { code: EXIT.USAGE, message: "vault restore requires a vault id" } };
|
|
3615
|
+
}
|
|
3616
|
+
return { args: { sub: "restore", id: targetId, json, account } };
|
|
3617
|
+
}
|
|
3618
|
+
const effectivePath = pathArg ?? positional;
|
|
3619
|
+
if (!effectivePath) {
|
|
3620
|
+
return { error: { code: EXIT.USAGE, message: "vault register requires --path <dir>" } };
|
|
3621
|
+
}
|
|
3622
|
+
return { args: { sub: "register", path: effectivePath, id, name, workspaceId, json } };
|
|
3623
|
+
}
|
|
3624
|
+
function accountPointerDeps(deps) {
|
|
3625
|
+
return { registryOpts: deps.registryOpts, ...deps.neon ?? {} };
|
|
3626
|
+
}
|
|
3627
|
+
function accountId(deps) {
|
|
3628
|
+
return deps.accountId ?? resolveCliAccountId();
|
|
3629
|
+
}
|
|
3630
|
+
async function resolveEffectiveAccountId(deps, requestedAccount, verb) {
|
|
3631
|
+
const scope = await resolveScopedAccountId(accountId(deps), requestedAccount);
|
|
3632
|
+
if (scope.ok) return { ok: true, accountId: scope.accountId };
|
|
3633
|
+
return {
|
|
3634
|
+
ok: false,
|
|
3635
|
+
result: {
|
|
3636
|
+
code: EXIT.FORBIDDEN,
|
|
3637
|
+
message: `hs vault ${verb}: ${scope.message}`,
|
|
3638
|
+
payload: { ok: false, error: scope.code, message: scope.message },
|
|
3639
|
+
isError: true
|
|
3640
|
+
}
|
|
3641
|
+
};
|
|
3642
|
+
}
|
|
3643
|
+
function slugFromPath(p) {
|
|
3644
|
+
const base = p.split(/[\\/]/).filter(Boolean).pop() ?? "";
|
|
3645
|
+
const slug = base.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3646
|
+
return slug;
|
|
3647
|
+
}
|
|
3648
|
+
function buildRegistryLookup2(deps) {
|
|
3649
|
+
return (vaultId) => {
|
|
3650
|
+
const registry = loadVaultRegistry(deps.registryOpts);
|
|
3651
|
+
const record = registry.vaults.find((v) => v.id === vaultId);
|
|
3652
|
+
return record ? { id: record.id, repoRootAbs: record.repo_root_abs } : null;
|
|
3653
|
+
};
|
|
3654
|
+
}
|
|
3655
|
+
function buildAccountPointerLookup2(deps) {
|
|
3656
|
+
return (accId) => getActiveVault(accId ?? accountId(deps), accountPointerDeps(deps));
|
|
3657
|
+
}
|
|
3658
|
+
function formatVaultRow(v, activeId) {
|
|
3659
|
+
const marker = v.id === activeId ? "* " : " ";
|
|
3660
|
+
return `${marker}${v.id} (workspace: ${v.workspace_id}) ${v.repo_root_abs}`;
|
|
3661
|
+
}
|
|
3662
|
+
async function runList(args, deps) {
|
|
3663
|
+
const scoped = await resolveEffectiveAccountId(deps, args.account, "list");
|
|
3664
|
+
if (!scoped.ok) return scoped.result;
|
|
3665
|
+
const { vaults: vaults2, active_vault_id } = await listVaultsForWorkspace(scoped.accountId, void 0, {
|
|
3666
|
+
registryOpts: deps.registryOpts,
|
|
3667
|
+
accountPointer: accountPointerDeps(deps),
|
|
3668
|
+
// The CLI runs on this machine AS this machine's owner, so the local
|
|
3669
|
+
// registry.json IS this caller's own data — the one place the offline
|
|
3670
|
+
// fallback is correct. A route handler must never pass this. NOT true
|
|
3671
|
+
// any more once `--account` targets someone ELSE's account (see
|
|
3672
|
+
// `workspace.ts`'s identical `runList` comment for why).
|
|
3673
|
+
allowLocalRegistryFallback: scoped.accountId === accountId(deps)
|
|
3674
|
+
});
|
|
3675
|
+
if (args.json) {
|
|
3676
|
+
return {
|
|
3677
|
+
code: EXIT.OK,
|
|
3678
|
+
message: "",
|
|
3679
|
+
payload: { ok: true, vaults: vaults2, active_vault_id },
|
|
3680
|
+
isError: false
|
|
3681
|
+
};
|
|
3682
|
+
}
|
|
3683
|
+
const lines = vaults2.length ? vaults2.map((v) => formatVaultRow(v, active_vault_id)).join("\n") : " (no vaults registered -- run `hs vault register --path <dir>`)";
|
|
3684
|
+
return {
|
|
3685
|
+
code: EXIT.OK,
|
|
3686
|
+
message: `${vaults2.length} registered vault(s):
|
|
3687
|
+
${lines}`,
|
|
3688
|
+
payload: { ok: true, vaults: vaults2, active_vault_id },
|
|
3689
|
+
isError: false
|
|
3690
|
+
};
|
|
3691
|
+
}
|
|
3692
|
+
async function runRegister(args, deps) {
|
|
3693
|
+
const resolvedPath = deps.resolvePath(args.path);
|
|
3694
|
+
const specsDir = deps.joinPath(resolvedPath, "specs");
|
|
3695
|
+
if (!deps.fileExists(specsDir)) {
|
|
3696
|
+
return {
|
|
3697
|
+
code: EXIT.INVALID_ARG,
|
|
3698
|
+
message: `hs vault register: no specs/ directory found at ${specsDir} -- this does not look like a HeySpex vault. Run \`hs install\` first, or pass --path at the repo root (the directory that CONTAINS specs/).`,
|
|
3699
|
+
payload: { ok: false, error: "no_specs_dir", path: resolvedPath },
|
|
3700
|
+
isError: true
|
|
3701
|
+
};
|
|
3702
|
+
}
|
|
3703
|
+
let id = args.id;
|
|
3704
|
+
let schemaVersion = 1;
|
|
3705
|
+
const yamlPath = deps.joinPath(resolvedPath, "heyspex.yaml");
|
|
3706
|
+
if (deps.fileExists(yamlPath)) {
|
|
3707
|
+
const content = deps.readFile(yamlPath);
|
|
3708
|
+
if (content) {
|
|
3709
|
+
if (!id) {
|
|
3710
|
+
const existingId = parseVaultId(content);
|
|
3711
|
+
if (existingId) id = existingId;
|
|
3712
|
+
}
|
|
3713
|
+
const versionMatch = content.match(/^schema_version:\s*(\d+)\s*$/m);
|
|
3714
|
+
if (versionMatch) schemaVersion = Number(versionMatch[1]);
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
if (!id) id = slugFromPath(resolvedPath);
|
|
3718
|
+
if (!id) {
|
|
3719
|
+
return {
|
|
3720
|
+
code: EXIT.INVALID_ARG,
|
|
3721
|
+
message: `hs vault register: could not derive a vault id from ${resolvedPath} -- pass --id explicitly.`,
|
|
3722
|
+
payload: { ok: false, error: "no_derivable_id", path: resolvedPath },
|
|
3723
|
+
isError: true
|
|
3724
|
+
};
|
|
3725
|
+
}
|
|
3726
|
+
const displayName = args.name ?? deps.basename(resolvedPath);
|
|
3727
|
+
const now = deps.now();
|
|
3728
|
+
const registryBefore = loadVaultRegistry(deps.registryOpts);
|
|
3729
|
+
const existingVaultRow = registryBefore.vaults.find(
|
|
3730
|
+
(v) => v.id === id || v.repo_root_abs === resolvedPath
|
|
3731
|
+
);
|
|
3732
|
+
const workspaceId = args.workspaceId ?? existingVaultRow?.workspace_id ?? "default";
|
|
3733
|
+
const workspaceExisted = registryBefore.workspaces.some((w) => w.id === workspaceId);
|
|
3734
|
+
if (!workspaceExisted) {
|
|
3735
|
+
registerWorkspace(
|
|
3736
|
+
{ id: workspaceId, display_name: workspaceId, created_at: now },
|
|
3737
|
+
deps.registryOpts
|
|
3738
|
+
);
|
|
3739
|
+
}
|
|
3740
|
+
registerVault(
|
|
3741
|
+
{
|
|
3742
|
+
id,
|
|
3743
|
+
workspace_id: workspaceId,
|
|
3744
|
+
display_name: displayName,
|
|
3745
|
+
repo_root_abs: resolvedPath,
|
|
3746
|
+
schema_version: schemaVersion,
|
|
3747
|
+
registered_at: now
|
|
3748
|
+
},
|
|
3749
|
+
deps.registryOpts
|
|
3750
|
+
);
|
|
3751
|
+
const neonWorkspaceWritten = workspaceExisted ? false : await upsertWorkspaceNeon(
|
|
3752
|
+
{ id: workspaceId, displayName: workspaceId },
|
|
3753
|
+
accountId(deps),
|
|
3754
|
+
deps.neon
|
|
3755
|
+
);
|
|
3756
|
+
const neonVaultWritten = await upsertVaultNeon(
|
|
3757
|
+
{ id, workspaceId, displayName, repoRootAbs: resolvedPath, schemaVersion },
|
|
3758
|
+
accountId(deps),
|
|
3759
|
+
deps.neon
|
|
3760
|
+
);
|
|
3761
|
+
let conflictReport = null;
|
|
3762
|
+
let conflictHits = [];
|
|
3763
|
+
if (deps.conflictScan) {
|
|
3764
|
+
conflictHits = scanInstallConflicts(resolvedPath, deps.conflictScan);
|
|
3765
|
+
conflictReport = formatConflictReport(conflictHits);
|
|
3766
|
+
}
|
|
3767
|
+
const payload = {
|
|
3768
|
+
ok: true,
|
|
3769
|
+
id,
|
|
3770
|
+
workspace_id: workspaceId,
|
|
3771
|
+
workspace_created: !workspaceExisted,
|
|
3772
|
+
display_name: displayName,
|
|
3773
|
+
repo_root_abs: resolvedPath,
|
|
3774
|
+
schema_version: schemaVersion,
|
|
3775
|
+
neon_workspace_written: neonWorkspaceWritten,
|
|
3776
|
+
neon_vault_written: neonVaultWritten,
|
|
3777
|
+
conflicts: conflictHits
|
|
3778
|
+
};
|
|
3779
|
+
const message = [
|
|
3780
|
+
`Registered vault "${id}" (workspace: ${workspaceId}) -> ${resolvedPath}`,
|
|
3781
|
+
...conflictReport && conflictHits.length > 0 ? ["", conflictReport] : []
|
|
3782
|
+
].join("\n");
|
|
3783
|
+
return { code: EXIT.OK, message, payload, isError: false };
|
|
3784
|
+
}
|
|
3785
|
+
async function runSwitch(args, deps) {
|
|
3786
|
+
try {
|
|
3787
|
+
await setActiveVault(accountId(deps), args.id, accountPointerDeps(deps));
|
|
3788
|
+
} catch (err) {
|
|
3789
|
+
if (err instanceof UnknownVaultIdError) {
|
|
3790
|
+
return {
|
|
3791
|
+
code: EXIT.NOT_FOUND,
|
|
3792
|
+
message: `hs vault switch: unknown vault id "${args.id}" -- run \`hs vault list\` to see registered vaults. Nothing was changed.`,
|
|
3793
|
+
payload: { ok: false, error: "unknown_vault_id", vault_id: args.id },
|
|
3794
|
+
isError: true
|
|
3795
|
+
};
|
|
3796
|
+
}
|
|
3797
|
+
throw err;
|
|
3798
|
+
}
|
|
3799
|
+
const registry = loadVaultRegistry(deps.registryOpts);
|
|
3800
|
+
const local = registry.vaults.find((v) => v.id === args.id);
|
|
3801
|
+
const workspaceId = local?.workspace_id ?? await findVaultWorkspaceIdNeon(args.id, deps.neon);
|
|
3802
|
+
let workspaceSwitched = false;
|
|
3803
|
+
if (workspaceId) {
|
|
3804
|
+
try {
|
|
3805
|
+
const { setActiveWorkspace: setActiveWorkspace2 } = await Promise.resolve().then(() => (init_account_pointer(), account_pointer_exports));
|
|
3806
|
+
await setActiveWorkspace2(accountId(deps), workspaceId, accountPointerDeps(deps));
|
|
3807
|
+
workspaceSwitched = true;
|
|
3808
|
+
} catch {
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
return {
|
|
3812
|
+
code: EXIT.OK,
|
|
3813
|
+
message: `Switched active vault to "${args.id}"${workspaceSwitched ? ` (workspace: ${workspaceId})` : ""}.`,
|
|
3814
|
+
payload: { ok: true, vault_id: args.id, workspace_id: workspaceSwitched ? workspaceId : null },
|
|
3815
|
+
isError: false
|
|
3816
|
+
};
|
|
3817
|
+
}
|
|
3818
|
+
async function activeVaultSelection(deps, effectiveAccountId = accountId(deps)) {
|
|
3819
|
+
const vaultId = await getActiveVault(effectiveAccountId, accountPointerDeps(deps));
|
|
3820
|
+
const workspaceId = await getActiveWorkspace(effectiveAccountId, accountPointerDeps(deps));
|
|
3821
|
+
const line = vaultId ? `Active vault: ${vaultId}${workspaceId ? ` (workspace: ${workspaceId})` : ""}` : "Active vault: (none)";
|
|
3822
|
+
return { line, vaultId, workspaceId };
|
|
3823
|
+
}
|
|
3824
|
+
function statusForVaultManageError(code) {
|
|
3825
|
+
return code === "vault_not_found" ? EXIT.NOT_FOUND : EXIT.INVALID_ARG;
|
|
3826
|
+
}
|
|
3827
|
+
async function runRename(args, deps) {
|
|
3828
|
+
const scoped = await resolveEffectiveAccountId(deps, args.account, "rename");
|
|
3829
|
+
if (!scoped.ok) return scoped.result;
|
|
3830
|
+
const result = await renameVault(
|
|
3831
|
+
{ accountId: scoped.accountId, id: args.id, displayName: args.name },
|
|
3832
|
+
{ registryOpts: deps.registryOpts, neon: deps.neon }
|
|
3833
|
+
);
|
|
3834
|
+
if (!result.ok) {
|
|
3835
|
+
return {
|
|
3836
|
+
code: statusForVaultManageError(result.code),
|
|
3837
|
+
message: `hs vault rename: ${result.message}`,
|
|
3838
|
+
payload: { ok: false, error: result.code, message: result.message },
|
|
3839
|
+
isError: true
|
|
3840
|
+
};
|
|
3841
|
+
}
|
|
3842
|
+
const selection = await activeVaultSelection(deps, scoped.accountId);
|
|
3843
|
+
return {
|
|
3844
|
+
code: EXIT.OK,
|
|
3845
|
+
message: `Renamed vault "${result.vault.id}" to "${result.vault.display_name}".
|
|
3846
|
+
${selection.line}`,
|
|
3847
|
+
payload: {
|
|
3848
|
+
ok: true,
|
|
3849
|
+
id: result.vault.id,
|
|
3850
|
+
display_name: result.vault.display_name,
|
|
3851
|
+
active_vault_id: selection.vaultId,
|
|
3852
|
+
active_workspace_id: selection.workspaceId
|
|
3853
|
+
},
|
|
3854
|
+
isError: false
|
|
3855
|
+
};
|
|
3856
|
+
}
|
|
3857
|
+
async function runMove(args, deps) {
|
|
3858
|
+
const scoped = await resolveEffectiveAccountId(deps, args.account, "move");
|
|
3859
|
+
if (!scoped.ok) return scoped.result;
|
|
3860
|
+
const result = await moveVault(
|
|
3861
|
+
{ accountId: scoped.accountId, id: args.id, workspaceId: args.workspaceId },
|
|
3862
|
+
{ registryOpts: deps.registryOpts, neon: deps.neon }
|
|
3863
|
+
);
|
|
3864
|
+
if (!result.ok) {
|
|
3865
|
+
return {
|
|
3866
|
+
code: statusForVaultManageError(result.code),
|
|
3867
|
+
message: `hs vault move: ${result.message}`,
|
|
3868
|
+
payload: { ok: false, error: result.code, message: result.message },
|
|
3869
|
+
isError: true
|
|
3870
|
+
};
|
|
3871
|
+
}
|
|
3872
|
+
const selection = await activeVaultSelection(deps, scoped.accountId);
|
|
3873
|
+
return {
|
|
3874
|
+
code: EXIT.OK,
|
|
3875
|
+
message: `Moved vault "${result.vault.id}" to workspace "${result.vault.workspace_id}".
|
|
3876
|
+
${selection.line}`,
|
|
3877
|
+
payload: {
|
|
3878
|
+
ok: true,
|
|
3879
|
+
id: result.vault.id,
|
|
3880
|
+
workspace_id: result.vault.workspace_id,
|
|
3881
|
+
active_vault_id: selection.vaultId,
|
|
3882
|
+
active_workspace_id: selection.workspaceId
|
|
3883
|
+
},
|
|
3884
|
+
isError: false
|
|
3885
|
+
};
|
|
3886
|
+
}
|
|
3887
|
+
async function confirmRemoval(deps, displayName, verb, yes) {
|
|
3888
|
+
if (yes) return true;
|
|
3889
|
+
const confirm2 = buildCloseNotDestroyConfirm("vault", verb, displayName);
|
|
3890
|
+
if (!deps.confirm) return false;
|
|
3891
|
+
return deps.confirm(`${confirm2.message} ${confirm2.confirmLabel}? (y/n) `);
|
|
3892
|
+
}
|
|
3893
|
+
function cancelledResult(kind) {
|
|
3894
|
+
return {
|
|
3895
|
+
code: EXIT.USAGE,
|
|
3896
|
+
message: `Stopped before making changes -- re-run with --yes, or answer "y", to ${kind}.`,
|
|
3897
|
+
payload: { ok: false, aborted: true },
|
|
3898
|
+
isError: true
|
|
3899
|
+
};
|
|
3900
|
+
}
|
|
3901
|
+
async function runUnregister(args, deps) {
|
|
3902
|
+
const scoped = await resolveEffectiveAccountId(deps, args.account, "unregister");
|
|
3903
|
+
if (!scoped.ok) return scoped.result;
|
|
3904
|
+
const registry = loadVaultRegistry(deps.registryOpts);
|
|
3905
|
+
const existing = registry.vaults.find((v) => v.id === args.id);
|
|
3906
|
+
const displayName = existing?.display_name ?? args.id;
|
|
3907
|
+
const proceed = await confirmRemoval(deps, displayName, "unregister", args.yes);
|
|
3908
|
+
if (!proceed) return cancelledResult("unregister");
|
|
3909
|
+
const result = await unregisterVault(
|
|
3910
|
+
{ accountId: scoped.accountId, id: args.id },
|
|
3911
|
+
{ registryOpts: deps.registryOpts, neon: deps.neon }
|
|
3912
|
+
);
|
|
3913
|
+
if (!result.ok) {
|
|
3914
|
+
return {
|
|
3915
|
+
code: statusForVaultManageError(result.code),
|
|
3916
|
+
message: `hs vault unregister: ${result.message}`,
|
|
3917
|
+
payload: { ok: false, error: result.code, message: result.message },
|
|
3918
|
+
isError: true
|
|
3919
|
+
};
|
|
3920
|
+
}
|
|
3921
|
+
const selection = await activeVaultSelection(deps, scoped.accountId);
|
|
3922
|
+
return {
|
|
3923
|
+
code: EXIT.OK,
|
|
3924
|
+
message: `Removed vault "${result.vault.id}" from HeySpex. ${CLOSE_NOT_DESTROY_STATEMENT}
|
|
3925
|
+
${selection.line}`,
|
|
3926
|
+
payload: {
|
|
3927
|
+
ok: true,
|
|
3928
|
+
id: result.vault.id,
|
|
3929
|
+
active_vault_id: selection.vaultId,
|
|
3930
|
+
active_workspace_id: selection.workspaceId
|
|
3931
|
+
},
|
|
3932
|
+
isError: false
|
|
3933
|
+
};
|
|
3934
|
+
}
|
|
3935
|
+
async function runArchive(args, deps) {
|
|
3936
|
+
const scoped = await resolveEffectiveAccountId(deps, args.account, "archive");
|
|
3937
|
+
if (!scoped.ok) return scoped.result;
|
|
3938
|
+
const registry = loadVaultRegistry(deps.registryOpts);
|
|
3939
|
+
const existing = registry.vaults.find((v) => v.id === args.id);
|
|
3940
|
+
const displayName = existing?.display_name ?? args.id;
|
|
3941
|
+
const proceed = await confirmRemoval(deps, displayName, "archive", args.yes);
|
|
3942
|
+
if (!proceed) return cancelledResult("archive");
|
|
3943
|
+
const result = await archiveVault(
|
|
3944
|
+
{ accountId: scoped.accountId, id: args.id },
|
|
3945
|
+
{ registryOpts: deps.registryOpts, neon: deps.neon }
|
|
3946
|
+
);
|
|
3947
|
+
if (!result.ok) {
|
|
3948
|
+
return {
|
|
3949
|
+
code: EXIT.NOT_FOUND,
|
|
3950
|
+
message: `hs vault archive: ${result.message}`,
|
|
3951
|
+
payload: { ok: false, error: result.code, message: result.message },
|
|
3952
|
+
isError: true
|
|
3953
|
+
};
|
|
3954
|
+
}
|
|
3955
|
+
const selection = await activeVaultSelection(deps, scoped.accountId);
|
|
3956
|
+
return {
|
|
3957
|
+
code: EXIT.OK,
|
|
3958
|
+
message: `Archived vault "${result.vault.id}". ${CLOSE_NOT_DESTROY_STATEMENT}
|
|
3959
|
+
${selection.line}`,
|
|
3960
|
+
payload: {
|
|
3961
|
+
ok: true,
|
|
3962
|
+
id: result.vault.id,
|
|
3963
|
+
archived: result.vault.archived,
|
|
3964
|
+
active_vault_id: selection.vaultId,
|
|
3965
|
+
active_workspace_id: selection.workspaceId
|
|
3966
|
+
},
|
|
3967
|
+
isError: false
|
|
3968
|
+
};
|
|
3969
|
+
}
|
|
3970
|
+
async function runRestore(args, deps) {
|
|
3971
|
+
const scoped = await resolveEffectiveAccountId(deps, args.account, "restore");
|
|
3972
|
+
if (!scoped.ok) return scoped.result;
|
|
3973
|
+
const result = await restoreVault(
|
|
3974
|
+
{ accountId: scoped.accountId, id: args.id },
|
|
3975
|
+
{ registryOpts: deps.registryOpts, neon: deps.neon }
|
|
3976
|
+
);
|
|
3977
|
+
if (!result.ok) {
|
|
3978
|
+
return {
|
|
3979
|
+
code: EXIT.NOT_FOUND,
|
|
3980
|
+
message: `hs vault restore: ${result.message}`,
|
|
3981
|
+
payload: { ok: false, error: result.code, message: result.message },
|
|
3982
|
+
isError: true
|
|
3983
|
+
};
|
|
3984
|
+
}
|
|
3985
|
+
const selection = await activeVaultSelection(deps, scoped.accountId);
|
|
3986
|
+
return {
|
|
3987
|
+
code: EXIT.OK,
|
|
3988
|
+
message: `Restored vault "${result.vault.id}".
|
|
3989
|
+
${selection.line}`,
|
|
3990
|
+
payload: {
|
|
3991
|
+
ok: true,
|
|
3992
|
+
id: result.vault.id,
|
|
3993
|
+
archived: result.vault.archived,
|
|
3994
|
+
active_vault_id: selection.vaultId,
|
|
3995
|
+
active_workspace_id: selection.workspaceId
|
|
3996
|
+
},
|
|
3997
|
+
isError: false
|
|
3998
|
+
};
|
|
3999
|
+
}
|
|
4000
|
+
async function runActive(args, deps) {
|
|
4001
|
+
if (args.vaultIdOverride !== void 0 && await buildRegistryLookup2(deps)(args.vaultIdOverride) === null) {
|
|
4002
|
+
const message = `hs vault: unknown vault id "${args.vaultIdOverride}" -- run \`hs vault list\` to see registered vaults.`;
|
|
4003
|
+
return {
|
|
4004
|
+
code: EXIT.NOT_FOUND,
|
|
4005
|
+
message,
|
|
4006
|
+
payload: { ok: false, error: "unknown_vault_id", message },
|
|
4007
|
+
isError: true
|
|
4008
|
+
};
|
|
4009
|
+
}
|
|
4010
|
+
try {
|
|
4011
|
+
const resolved = await resolveActiveVaultRoot({
|
|
4012
|
+
vaultId: args.vaultIdOverride,
|
|
4013
|
+
accountId: accountId(deps),
|
|
4014
|
+
cwd: deps.cwd,
|
|
4015
|
+
registryLookup: buildRegistryLookup2(deps),
|
|
4016
|
+
accountPointerLookup: buildAccountPointerLookup2(deps)
|
|
4017
|
+
});
|
|
4018
|
+
const workspaceId = await getActiveWorkspace(accountId(deps), accountPointerDeps(deps));
|
|
4019
|
+
const payload = {
|
|
4020
|
+
ok: true,
|
|
4021
|
+
vaultId: resolved.vaultId,
|
|
4022
|
+
repoRootAbs: resolved.repoRootAbs,
|
|
4023
|
+
specsDirAbs: resolved.specsDirAbs,
|
|
4024
|
+
source: resolved.source,
|
|
4025
|
+
workspaceId
|
|
4026
|
+
};
|
|
4027
|
+
const message = [
|
|
4028
|
+
`vault: ${resolved.vaultId}`,
|
|
4029
|
+
`workspace: ${workspaceId ?? "(none)"}`,
|
|
4030
|
+
`repo root: ${resolved.repoRootAbs}`,
|
|
4031
|
+
`specs dir: ${resolved.specsDirAbs}`,
|
|
4032
|
+
`source: ${resolved.source}`
|
|
4033
|
+
].join("\n");
|
|
4034
|
+
return { code: EXIT.OK, message, payload, isError: false };
|
|
4035
|
+
} catch (err) {
|
|
4036
|
+
if (err instanceof NoActiveVaultError) {
|
|
4037
|
+
return {
|
|
4038
|
+
code: EXIT.NOT_FOUND,
|
|
4039
|
+
message: err.message,
|
|
4040
|
+
payload: { ok: false, error: "no_active_vault", message: err.message },
|
|
4041
|
+
isError: true
|
|
4042
|
+
};
|
|
4043
|
+
}
|
|
4044
|
+
throw err;
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
async function runVault(args, deps, vaultIdOverride) {
|
|
4048
|
+
if (args.sub === "list") return runList(args, deps);
|
|
4049
|
+
if (args.sub === "register") return runRegister(args, deps);
|
|
4050
|
+
if (args.sub === "switch") return runSwitch(args, deps);
|
|
4051
|
+
if (args.sub === "rename") return runRename(args, deps);
|
|
4052
|
+
if (args.sub === "move") return runMove(args, deps);
|
|
4053
|
+
if (args.sub === "unregister") return runUnregister(args, deps);
|
|
4054
|
+
if (args.sub === "archive") return runArchive(args, deps);
|
|
4055
|
+
if (args.sub === "restore") return runRestore(args, deps);
|
|
4056
|
+
return runActive({ json: args.json, vaultIdOverride }, deps);
|
|
4057
|
+
}
|
|
4058
|
+
var HELP3 = [
|
|
4059
|
+
"Usage:",
|
|
4060
|
+
" hs vault list [--json] List registered vaults, mark active",
|
|
4061
|
+
' hs vault register --path <dir> [--id <slug>] [--name "..."] [--workspace <id>]',
|
|
4062
|
+
" Register a vault (idempotent by path)",
|
|
4063
|
+
" hs vault switch <id> [--json] Set the active vault (+ active workspace)",
|
|
4064
|
+
" hs vault active [--json] Show the active workspace + vault + paths",
|
|
4065
|
+
' hs vault rename <id> --name "<name>" Rename a vault',
|
|
4066
|
+
" hs vault move <id> --workspace <id> Move a vault to another workspace",
|
|
4067
|
+
" hs vault unregister <id> [--yes] Remove a vault from HeySpex (files untouched)",
|
|
4068
|
+
" hs vault archive <id> [--yes] Archive a vault (reversible)",
|
|
4069
|
+
" hs vault restore <id> Restore an archived vault",
|
|
4070
|
+
"",
|
|
4071
|
+
"'workspace' = company container; 'vault' = one repo's specs/. Never used interchangeably.",
|
|
4072
|
+
"",
|
|
4073
|
+
"Global one-shot override (any hs subcommand):",
|
|
4074
|
+
" hs --vault <id> <subcommand> Use <id> for this invocation only; does not persist a new default.",
|
|
4075
|
+
"",
|
|
4076
|
+
"register validates <dir>/specs/ exists (no write otherwise), re-runs the install",
|
|
4077
|
+
"conflict scan (warn-only), and creates the --workspace inline if it doesn't exist yet.",
|
|
4078
|
+
"switch fails closed on an unknown id (no pointer mutation) and names `hs vault list` as the fix.",
|
|
4079
|
+
"rename/move/unregister/archive/restore call the SAME REST core as the console app",
|
|
4080
|
+
"switcher (PATCH|DELETE /api/vaults/:id) -- no separate CLI-only path. unregister and",
|
|
4081
|
+
"archive show the shared Close-is-not-Destroy confirm (files on disk are never touched);",
|
|
4082
|
+
"--yes skips the prompt only, it never changes what the verb does. Every management verb",
|
|
4083
|
+
"prints the resulting active vault (+ workspace) afterward, since a removal or archive of",
|
|
4084
|
+
"the active vault may re-point the active pointer server-side.",
|
|
4085
|
+
"",
|
|
4086
|
+
"Flags:",
|
|
4087
|
+
" --path <dir> Repo root to register (register).",
|
|
4088
|
+
" --id <slug> Vault id (register; default: heyspex.yaml's vault_id, else a slug from --path).",
|
|
4089
|
+
" --name <name> Display name (register/rename; default on register: the directory basename).",
|
|
4090
|
+
' --workspace <id> Owning workspace id (register/move; default on register: "default").',
|
|
4091
|
+
" --yes, -y Skip the removal confirm prompt (unregister/archive).",
|
|
4092
|
+
" --account <id> Act on a DIFFERENT account's vault instead of your own",
|
|
4093
|
+
" (list/rename/move/unregister/archive/restore only) -- builder/admin-gated",
|
|
4094
|
+
" server-side; refused for a non-builder caller (exit 15).",
|
|
4095
|
+
" --json Emit a machine-readable JSON result.",
|
|
4096
|
+
" --help, -h Show this help.",
|
|
4097
|
+
"",
|
|
4098
|
+
"Exit codes: 0 ok \xB7 1 usage \xB7 10 not-found \xB7 11 bad-arg \xB7 15 forbidden (--account)"
|
|
4099
|
+
].join("\n");
|
|
4100
|
+
|
|
4101
|
+
// spex-console/bin/hs-vault.ts
|
|
4102
|
+
function readFile(p) {
|
|
4103
|
+
try {
|
|
4104
|
+
return readFileSync6(p, "utf8");
|
|
4105
|
+
} catch {
|
|
4106
|
+
return null;
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
var INTERACTIVE = process2.stdin.isTTY === true;
|
|
4110
|
+
async function confirm(question) {
|
|
4111
|
+
if (!INTERACTIVE) return false;
|
|
4112
|
+
const rl = createInterface({ input: process2.stdin, output: process2.stdout });
|
|
4113
|
+
try {
|
|
4114
|
+
const answer = (await rl.question(question)).trim().toLowerCase();
|
|
4115
|
+
return answer === "y" || answer === "yes" || answer === "proceed";
|
|
4116
|
+
} finally {
|
|
4117
|
+
rl.close();
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
async function main() {
|
|
4121
|
+
const parsed = parseVaultArgs(process2.argv.slice(3));
|
|
4122
|
+
if (parsed.help) {
|
|
4123
|
+
process2.stdout.write(`${HELP3}
|
|
4124
|
+
`);
|
|
4125
|
+
process2.exit(EXIT.OK);
|
|
4126
|
+
}
|
|
4127
|
+
if (parsed.error) {
|
|
4128
|
+
process2.stderr.write(`hs vault: ${parsed.error.message}
|
|
4129
|
+
`);
|
|
4130
|
+
process2.exit(parsed.error.code);
|
|
4131
|
+
}
|
|
4132
|
+
const vaultIdOverride = process2.env.HS_VAULT_ID_OVERRIDE?.trim() || void 0;
|
|
4133
|
+
const cwd = process2.env.HS_VAULT_ROOT ? path6.resolve(process2.env.HS_VAULT_ROOT) : process2.cwd();
|
|
4134
|
+
const invokedCwd = process2.env.HS_INVOKED_CWD?.trim() || process2.cwd();
|
|
4135
|
+
const consoleUrl = resolveSpexConsoleUrl(cwd, readFile);
|
|
4136
|
+
const result = await runVault(
|
|
4137
|
+
parsed.args,
|
|
4138
|
+
{
|
|
4139
|
+
fileExists: (p) => existsSync5(p),
|
|
4140
|
+
readFile,
|
|
4141
|
+
resolvePath: (p) => path6.resolve(invokedCwd, p),
|
|
4142
|
+
joinPath: (...s) => path6.join(...s),
|
|
4143
|
+
basename: (p) => path6.basename(p),
|
|
4144
|
+
now: () => (/* @__PURE__ */ new Date()).toISOString(),
|
|
4145
|
+
cwd,
|
|
4146
|
+
conflictScan: {
|
|
4147
|
+
fileExists: (p) => existsSync5(p),
|
|
4148
|
+
readFile,
|
|
4149
|
+
joinPath: (...s) => path6.join(...s)
|
|
4150
|
+
},
|
|
4151
|
+
confirm,
|
|
4152
|
+
neon: { http: { fetchImpl: fetch, consoleUrl, token: readPairedToken() } }
|
|
4153
|
+
},
|
|
4154
|
+
vaultIdOverride
|
|
4155
|
+
);
|
|
4156
|
+
const sink = result.isError ? process2.stderr : process2.stdout;
|
|
4157
|
+
sink.write(`${parsed.args.json ? JSON.stringify(result.payload) : result.message}
|
|
4158
|
+
`);
|
|
4159
|
+
process2.exit(result.code);
|
|
4160
|
+
}
|
|
4161
|
+
main().catch((err) => {
|
|
4162
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4163
|
+
process2.stderr.write(`hs vault: unhandled: ${message}
|
|
4164
|
+
`);
|
|
4165
|
+
process2.exit(EXIT.WRITE_FAILED);
|
|
4166
|
+
});
|