@nurix/nustack 0.25.5 → 0.26.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/dist/commands/doctor.js +1 -1
- package/dist/commands/store.js +2 -2
- package/dist/lib/desktop_settings.js +40 -1
- package/dist/lib/gate/agentHook.js +20 -3
- package/dist/lib/gate/taskRouter.js +106 -0
- package/dist/lib/lane/decide.js +1 -1
- package/dist/lib/local_store/device_state.js +2 -2
- package/dist/lib/store/open.js +11 -9
- package/dist/lib/store/wipe.js +16 -2
- package/dist/machine.sql +6 -0
- package/dist/machine.version +1 -0
- package/package.json +3 -3
package/dist/commands/doctor.js
CHANGED
|
@@ -85,7 +85,7 @@ export async function runDoctor(options) {
|
|
|
85
85
|
}
|
|
86
86
|
else {
|
|
87
87
|
const stamp = store.db.prepare("PRAGMA user_version").get().user_version;
|
|
88
|
-
add("state", "Local store schema", "ok", `
|
|
88
|
+
add("state", "Local store schema", "ok", `schema ${stamp}`);
|
|
89
89
|
}
|
|
90
90
|
const repository = resolveRepositoryOrNull(cwd);
|
|
91
91
|
if (!repository) {
|
package/dist/commands/store.js
CHANGED
|
@@ -9,7 +9,7 @@ import { countRecords, releaseUnboundRows } from "../lib/records/recordsTable.js
|
|
|
9
9
|
import { drainSessionLegs } from "../lib/sessions/sessionEvents.js";
|
|
10
10
|
import { pullAll, pullCursor, pushOutbox } from "../lib/sync/client.js";
|
|
11
11
|
import { countOutbox, hasDueEntry, newestOutboxError } from "../lib/sync/outbox.js";
|
|
12
|
-
import { cliVersion,
|
|
12
|
+
import { cliVersion, machineSchemaVersion, machineStorePath } from "../lib/store/open.js";
|
|
13
13
|
const CLIENT_OUTDATED = "CLIENT_OUTDATED";
|
|
14
14
|
function answeredRefusal(command, options, summaries) {
|
|
15
15
|
for (const summary of summaries) {
|
|
@@ -133,7 +133,7 @@ export async function runStoreStatus(options) {
|
|
|
133
133
|
const result = {
|
|
134
134
|
path: machineStorePath(),
|
|
135
135
|
version: cliVersion(),
|
|
136
|
-
stamp:
|
|
136
|
+
stamp: machineSchemaVersion(),
|
|
137
137
|
cursor: pullCursor(store),
|
|
138
138
|
outbox: countOutbox(store.db, Date.now()),
|
|
139
139
|
records: tableCount("harness_records"),
|
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { desktopSettingsPath } from "./local_store/paths.js";
|
|
3
|
+
export const CLASSIFIERS = ["laya", "jev"];
|
|
4
|
+
export const TASK_KINDS = ["search", "explain", "review", "plan", "debug", "edit", "test", "docs", "run"];
|
|
5
|
+
export const READ_ONLY_TASKS = ["search", "explain", "review", "plan", "debug"];
|
|
6
|
+
export const ROUTE_MODES = ["auto", "local", "claude"];
|
|
7
|
+
export const CLAUDE_MODELS = ["haiku", "sonnet", "opus", "fable"];
|
|
8
|
+
export const DEFAULT_TASK_ROUTE = { mode: "auto", model: null };
|
|
9
|
+
const CLASSIFIER_URL_MAX = 200;
|
|
10
|
+
export function isLoopbackUrl(value) {
|
|
11
|
+
if (typeof value !== "string" || value.length > CLASSIFIER_URL_MAX || /\s/.test(value))
|
|
12
|
+
return false;
|
|
13
|
+
return ["http://127.0.0.1", "http://localhost", "http://[::1]"].some((host) => {
|
|
14
|
+
if (!value.startsWith(host))
|
|
15
|
+
return false;
|
|
16
|
+
const rest = value.slice(host.length);
|
|
17
|
+
return rest === "" || rest.startsWith(":") || rest.startsWith("/");
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function taskRoutesOf(value) {
|
|
21
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
22
|
+
return null;
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
25
|
+
const kind = TASK_KINDS.find((known) => known === key);
|
|
26
|
+
if (kind === undefined || !raw || typeof raw !== "object")
|
|
27
|
+
return null;
|
|
28
|
+
const route = raw;
|
|
29
|
+
const mode = ROUTE_MODES.find((known) => known === route.mode);
|
|
30
|
+
if (mode === undefined || (mode === "local" && !READ_ONLY_TASKS.includes(kind)))
|
|
31
|
+
return null;
|
|
32
|
+
const model = route.model === undefined || route.model === null ? null : CLAUDE_MODELS.find((known) => known === route.model);
|
|
33
|
+
if (model === undefined)
|
|
34
|
+
return null;
|
|
35
|
+
out[kind] = { mode, model };
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
3
39
|
export const GATE_THRESHOLD_TOKENS_MIN = 1_000;
|
|
4
40
|
export const GATE_THRESHOLD_TOKENS_MAX = 200_000;
|
|
5
41
|
export const DIGEST_BUDGET_MS_MIN = 5_000;
|
|
@@ -17,7 +53,7 @@ function sectionOf(value) {
|
|
|
17
53
|
export function readDesktopSettings() {
|
|
18
54
|
const defaults = {
|
|
19
55
|
placement: { memory: DEFAULT_PLACEMENT, securityFindings: DEFAULT_PLACEMENT },
|
|
20
|
-
routing: { localReader: false, gateThresholdTokens: null, digestBudgetMs: null, lane: false },
|
|
56
|
+
routing: { localReader: false, gateThresholdTokens: null, digestBudgetMs: null, lane: false, classifier: null, classifierUrl: null, taskRoutes: null },
|
|
21
57
|
};
|
|
22
58
|
let parsed;
|
|
23
59
|
try {
|
|
@@ -40,6 +76,9 @@ export function readDesktopSettings() {
|
|
|
40
76
|
gateThresholdTokens: boundedIntegerOf(routing.gateThresholdTokens, GATE_THRESHOLD_TOKENS_MIN, GATE_THRESHOLD_TOKENS_MAX),
|
|
41
77
|
digestBudgetMs: boundedIntegerOf(routing.digestBudgetMs, DIGEST_BUDGET_MS_MIN, DIGEST_BUDGET_MS_MAX),
|
|
42
78
|
lane: routing.lane === true,
|
|
79
|
+
classifier: CLASSIFIERS.find((known) => known === routing.classifier) ?? null,
|
|
80
|
+
classifierUrl: isLoopbackUrl(routing.classifierUrl) ? routing.classifierUrl : null,
|
|
81
|
+
taskRoutes: taskRoutesOf(routing.taskRoutes),
|
|
43
82
|
},
|
|
44
83
|
};
|
|
45
84
|
}
|
|
@@ -6,6 +6,7 @@ import { readSettingsLayers } from "../lane/credential.js";
|
|
|
6
6
|
import { LANE_AGENT_NAME, LANE_AGENT_ROUTE, LANE_MODEL_ID } from "../lane/install.js";
|
|
7
7
|
import { isUnderPrefix, readProxy } from "../lane/proxyRecord.js";
|
|
8
8
|
import { GateInputError } from "./readGate.js";
|
|
9
|
+
import { classifyTask, destinationFor } from "./taskRouter.js";
|
|
9
10
|
export const READ_ONLY_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "NotebookRead"]);
|
|
10
11
|
export const PASS_ON_AGENT_PERMISSION_RULE = true;
|
|
11
12
|
function parseAgentCall(text) {
|
|
@@ -111,7 +112,8 @@ export async function answerAgentCall(text, env, home = os.homedir()) {
|
|
|
111
112
|
const proxy = readProxy();
|
|
112
113
|
if (!proxy.live || !isUnderPrefix(baseUrl, proxy.prefix))
|
|
113
114
|
return null;
|
|
114
|
-
|
|
115
|
+
const { routing } = readDesktopSettings();
|
|
116
|
+
if (!routing.lane)
|
|
115
117
|
return null;
|
|
116
118
|
const model = call.toolInput.model;
|
|
117
119
|
if (model !== undefined && model !== null && model !== "haiku")
|
|
@@ -119,12 +121,27 @@ export async function answerAgentCall(text, env, home = os.homedir()) {
|
|
|
119
121
|
const type = call.toolInput.subagent_type;
|
|
120
122
|
if (typeof type !== "string")
|
|
121
123
|
return null;
|
|
122
|
-
|
|
124
|
+
const isRuleLocal = type === "Explore" || isReadOnlyDefinition(type, call.cwd, home);
|
|
125
|
+
if (routing.classifier === null && !isRuleLocal)
|
|
123
126
|
return null;
|
|
124
127
|
if (PASS_ON_AGENT_PERMISSION_RULE && hasAgentPermissionRule(readSettingsLayers({ env, cwd: call.cwd, home })))
|
|
125
128
|
return null;
|
|
126
129
|
if (await isLaneLatched(call.sessionId))
|
|
127
130
|
return null;
|
|
128
|
-
|
|
131
|
+
if (routing.classifier !== null) {
|
|
132
|
+
const decision = await classifyTask(routing.classifier, routing.classifierUrl, taskState(call.toolInput), env);
|
|
133
|
+
if (decision === null)
|
|
134
|
+
return null;
|
|
135
|
+
const destination = destinationFor(decision, routing.taskRoutes);
|
|
136
|
+
if (destination.to === "local")
|
|
137
|
+
return allow({ ...call.toolInput, subagent_type: LANE_AGENT_NAME });
|
|
138
|
+
return destination.model !== null && (model === undefined || model === null) ? allow({ ...call.toolInput, model: destination.model }) : null;
|
|
139
|
+
}
|
|
140
|
+
return allow(LANE_AGENT_ROUTE === "model" ? { ...call.toolInput, model: LANE_MODEL_ID } : { ...call.toolInput, subagent_type: LANE_AGENT_NAME });
|
|
141
|
+
}
|
|
142
|
+
export function taskState(toolInput) {
|
|
143
|
+
return [toolInput.description, toolInput.prompt].filter((part) => typeof part === "string" && part !== "").join("\n\n");
|
|
144
|
+
}
|
|
145
|
+
function allow(updatedInput) {
|
|
129
146
|
return `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput } })}\n`;
|
|
130
147
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { DEFAULT_TASK_ROUTE, READ_ONLY_TASKS } from "../desktop_settings.js";
|
|
4
|
+
const runFile = promisify(execFile);
|
|
5
|
+
export const LAYA_DEFAULT_BASE_URL = "http://127.0.0.1:11435";
|
|
6
|
+
export const JEV_BASE_URL = "https://api.typesafe.ai";
|
|
7
|
+
export const CLASSIFIER_MODELS = { laya: "laya", jev: "jev-latest" };
|
|
8
|
+
export const TASK_ROUTER_KEYCHAIN_SERVICE = "nustack-task-router";
|
|
9
|
+
export const TASK_ROUTER_KEYCHAIN_ACCOUNT = "typesafe-api-key";
|
|
10
|
+
export const CLASSIFY_TIMEOUT_MS = 1_500;
|
|
11
|
+
export const MIN_CONFIDENCE = 0.5;
|
|
12
|
+
export const STATE_MAX_CHARS = 4_000;
|
|
13
|
+
export const LOCAL_OK_THRESHOLD = 0.5;
|
|
14
|
+
export const TASK_KIND_CRITERIA = {
|
|
15
|
+
search: "Find files, code, symbols or usages in a codebase",
|
|
16
|
+
explain: "Read code or documents and explain or summarise them",
|
|
17
|
+
review: "Review or audit code for problems without changing it",
|
|
18
|
+
plan: "Design or plan a change before making it",
|
|
19
|
+
debug: "Diagnose why something fails, without fixing it",
|
|
20
|
+
edit: "Write, change, fix or refactor code or files",
|
|
21
|
+
test: "Write or update tests",
|
|
22
|
+
docs: "Write or update documentation",
|
|
23
|
+
run: "Run commands, builds, tests or scripts",
|
|
24
|
+
};
|
|
25
|
+
export function classifyRequest(classifier, state) {
|
|
26
|
+
return {
|
|
27
|
+
model: CLASSIFIER_MODELS[classifier],
|
|
28
|
+
state: state.slice(0, STATE_MAX_CHARS),
|
|
29
|
+
questions: {
|
|
30
|
+
task: { type: "choice", instructions: "What kind of work does this coding sub-agent task ask for?", criteria: TASK_KIND_CRITERIA },
|
|
31
|
+
local_ok: {
|
|
32
|
+
type: "noul",
|
|
33
|
+
instructions: "Could a small local model do this task well by reading files alone, with no edits and no commands?",
|
|
34
|
+
labels: { false: "needs Claude", true: "a small model can" },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function readDecision(body) {
|
|
40
|
+
if (body === null || typeof body !== "object")
|
|
41
|
+
return null;
|
|
42
|
+
const answers = body.answers;
|
|
43
|
+
const answer = answers?.task;
|
|
44
|
+
if (!answer)
|
|
45
|
+
return null;
|
|
46
|
+
const kind = Object.keys(TASK_KIND_CRITERIA).find((known) => known === answer.choice);
|
|
47
|
+
const confidence = typeof answer.answer_confidence === "number" ? answer.answer_confidence : answer.confidence;
|
|
48
|
+
if (kind === undefined || typeof confidence !== "number" || confidence < MIN_CONFIDENCE)
|
|
49
|
+
return null;
|
|
50
|
+
const localOk = answers?.local_ok?.noul;
|
|
51
|
+
return { kind, confidence, localOk: typeof localOk === "number" ? localOk : null };
|
|
52
|
+
}
|
|
53
|
+
export function destinationFor(decision, routes) {
|
|
54
|
+
const route = routes?.[decision.kind] ?? DEFAULT_TASK_ROUTE;
|
|
55
|
+
const isReadOnly = READ_ONLY_TASKS.includes(decision.kind);
|
|
56
|
+
if (route.mode === "local" && isReadOnly)
|
|
57
|
+
return { to: "local" };
|
|
58
|
+
if (route.mode === "auto" && isReadOnly && (decision.localOk ?? 0) >= LOCAL_OK_THRESHOLD)
|
|
59
|
+
return { to: "local" };
|
|
60
|
+
return { to: "claude", model: route.model };
|
|
61
|
+
}
|
|
62
|
+
export async function readJevKey(env) {
|
|
63
|
+
const fromEnv = env.TYPESAFE_API_KEY?.trim();
|
|
64
|
+
if (fromEnv)
|
|
65
|
+
return fromEnv;
|
|
66
|
+
const bin = env.NUSTACK_KEYCHAIN_BIN?.trim() || (process.platform === "darwin" ? "/usr/bin/security" : null);
|
|
67
|
+
if (bin === null)
|
|
68
|
+
return null;
|
|
69
|
+
try {
|
|
70
|
+
const { stdout } = await runFile(bin, ["find-generic-password", "-s", TASK_ROUTER_KEYCHAIN_SERVICE, "-a", TASK_ROUTER_KEYCHAIN_ACCOUNT, "-w"], {
|
|
71
|
+
env,
|
|
72
|
+
timeout: 1_000,
|
|
73
|
+
});
|
|
74
|
+
return stdout.trim() || null;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function classifierBase(classifier, classifierUrl, env) {
|
|
81
|
+
const base = classifier === "laya" ? (classifierUrl ?? LAYA_DEFAULT_BASE_URL) : env.TYPESAFE_BASE_URL?.trim() || JEV_BASE_URL;
|
|
82
|
+
return base.replace(/\/+$/, "");
|
|
83
|
+
}
|
|
84
|
+
export async function classifyTask(classifier, classifierUrl, state, env) {
|
|
85
|
+
const headers = { "content-type": "application/json" };
|
|
86
|
+
if (classifier === "jev") {
|
|
87
|
+
const key = await readJevKey(env);
|
|
88
|
+
if (key === null)
|
|
89
|
+
return null;
|
|
90
|
+
headers.authorization = `Bearer ${key}`;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const response = await fetch(`${classifierBase(classifier, classifierUrl, env)}/v1/systemone`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers,
|
|
96
|
+
body: JSON.stringify(classifyRequest(classifier, state)),
|
|
97
|
+
signal: AbortSignal.timeout(CLASSIFY_TIMEOUT_MS),
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok)
|
|
100
|
+
return null;
|
|
101
|
+
return readDecision(await response.json());
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/lib/lane/decide.js
CHANGED
|
@@ -9,7 +9,7 @@ export const SUBAGENT_MODEL_ENV = "CLAUDE_CODE_SUBAGENT_MODEL";
|
|
|
9
9
|
export const SUBAGENT_MODEL_OVERRIDES_ROUTE = false;
|
|
10
10
|
export const LANE_SENTENCES = {
|
|
11
11
|
unreleased: "The light-model lane is not released in this build.",
|
|
12
|
-
"switch-off": "The lane is switched off in
|
|
12
|
+
"switch-off": "The lane is switched off in Settings → Desktop app → Local models.",
|
|
13
13
|
"proxy-down": "NuStack's lane proxy is not running; open the NuStack desktop app.",
|
|
14
14
|
"bedrock-or-vertex": "This session runs on Bedrock or Vertex, which the lane does not carry.",
|
|
15
15
|
"subscription-credential": "This session signs in with a Claude subscription, which the lane never carries.",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { machineSchemaVersion } from "../store/open.js";
|
|
2
2
|
import { withTransaction } from "./database.js";
|
|
3
3
|
function mapRow(row) {
|
|
4
4
|
return {
|
|
@@ -62,7 +62,7 @@ export function upsertDeviceState(store, patch) {
|
|
|
62
62
|
active_organization_id: merged.activeOrganizationId,
|
|
63
63
|
keychain_service: merged.keychainService,
|
|
64
64
|
keychain_account: merged.keychainAccount,
|
|
65
|
-
schema_version:
|
|
65
|
+
schema_version: machineSchemaVersion(),
|
|
66
66
|
last_auth_refresh_at_ms: merged.lastAuthRefreshAtMs,
|
|
67
67
|
created_at_ms: merged.createdAtMs,
|
|
68
68
|
updated_at_ms: now,
|
package/dist/lib/store/open.js
CHANGED
|
@@ -4,18 +4,17 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { mkdirSecure } from "../secure_file.js";
|
|
6
6
|
import { nustackHomeDir } from "../local_store/paths.js";
|
|
7
|
-
import { StoreTooNewError, versionVerdict, wipeStaleStore } from "./wipe.js";
|
|
7
|
+
import { StoreTooNewError, restoreHeldFolders, versionVerdict, wipeStaleStore } from "./wipe.js";
|
|
8
8
|
export function machineStorePath(home = os.homedir()) {
|
|
9
9
|
return path.join(home, ".nustack", "nustack.db");
|
|
10
10
|
}
|
|
11
11
|
export function databaseFiles(dbFile) {
|
|
12
12
|
return [dbFile, `${dbFile}-wal`, `${dbFile}-shm`];
|
|
13
13
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return Number(match[1]) * 1000 + Number(match[2]);
|
|
14
|
+
let schemaVersionCache;
|
|
15
|
+
export function machineSchemaVersion() {
|
|
16
|
+
schemaVersionCache ??= Number(readFileSync(new URL("../../../dist/machine.version", import.meta.url), "utf8").trim());
|
|
17
|
+
return schemaVersionCache;
|
|
19
18
|
}
|
|
20
19
|
let cliVersionCache;
|
|
21
20
|
export function cliVersion() {
|
|
@@ -100,7 +99,8 @@ function settleSchema(db, stamp) {
|
|
|
100
99
|
export async function openMachineDatabase(dbFile = machineStorePath()) {
|
|
101
100
|
retireReplacedStores();
|
|
102
101
|
await mkdirSecure(path.dirname(dbFile));
|
|
103
|
-
const stamp =
|
|
102
|
+
const stamp = machineSchemaVersion();
|
|
103
|
+
let held = [];
|
|
104
104
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
105
105
|
const db = await withSuppressedSqliteWarning(() => openSqliteFile(dbFile));
|
|
106
106
|
try {
|
|
@@ -108,6 +108,8 @@ export async function openMachineDatabase(dbFile = machineStorePath()) {
|
|
|
108
108
|
db.exec("PRAGMA journal_mode = WAL");
|
|
109
109
|
db.exec("PRAGMA foreign_keys = OFF");
|
|
110
110
|
if (settleSchema(db, stamp)) {
|
|
111
|
+
if (held.length > 0)
|
|
112
|
+
restoreHeldFolders(db, held);
|
|
111
113
|
await chmod(dbFile, 0o600).catch(() => { });
|
|
112
114
|
return db;
|
|
113
115
|
}
|
|
@@ -116,7 +118,7 @@ export async function openMachineDatabase(dbFile = machineStorePath()) {
|
|
|
116
118
|
db.close();
|
|
117
119
|
throw error;
|
|
118
120
|
}
|
|
119
|
-
await wipeStaleStore(db, dbFile);
|
|
121
|
+
held = await wipeStaleStore(db, dbFile);
|
|
120
122
|
}
|
|
121
123
|
throw new Error(`the machine store at ${dbFile} could not be recreated`);
|
|
122
124
|
}
|
|
@@ -126,7 +128,7 @@ export async function openMachineDatabaseReadOnly(dbFile = machineStorePath()) {
|
|
|
126
128
|
const db = await withSuppressedSqliteWarning(() => openSqliteFile(dbFile, { readOnly: true }));
|
|
127
129
|
try {
|
|
128
130
|
db.exec("PRAGMA busy_timeout = 2000");
|
|
129
|
-
if (userVersionOf(db) ===
|
|
131
|
+
if (userVersionOf(db) === machineSchemaVersion())
|
|
130
132
|
return db;
|
|
131
133
|
}
|
|
132
134
|
catch {
|
package/dist/lib/store/wipe.js
CHANGED
|
@@ -6,19 +6,32 @@ export function versionVerdict(found, own) {
|
|
|
6
6
|
return "same";
|
|
7
7
|
return found > own ? "newer" : "older";
|
|
8
8
|
}
|
|
9
|
-
const releaseOf = (stamp) => `${Math.floor(stamp / 1000)}.${stamp % 1000}`;
|
|
10
9
|
export class StoreTooNewError extends Error {
|
|
11
10
|
code = "STORE_TOO_NEW";
|
|
12
11
|
foundStamp;
|
|
13
12
|
ownStamp;
|
|
14
13
|
constructor(foundStamp, ownStamp) {
|
|
15
|
-
super(`This machine's NuStack store
|
|
14
|
+
super(`This machine's NuStack store is schema ${foundStamp}, newer than this CLI reads (${ownStamp}). Update NuStack: ${UPGRADE_COMMAND}`);
|
|
16
15
|
this.name = "StoreTooNewError";
|
|
17
16
|
this.foundStamp = foundStamp;
|
|
18
17
|
this.ownStamp = ownStamp;
|
|
19
18
|
}
|
|
20
19
|
}
|
|
20
|
+
function heldFolders(db) {
|
|
21
|
+
try {
|
|
22
|
+
return db.prepare("SELECT path, label, project_id, added_at FROM folder_roster").all();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function restoreHeldFolders(db, held) {
|
|
29
|
+
const insert = db.prepare("INSERT OR IGNORE INTO folder_roster (path, label, project_id, added_at) VALUES (?, ?, ?, ?)");
|
|
30
|
+
for (const row of held)
|
|
31
|
+
insert.run(row.path, row.label, row.project_id, row.added_at);
|
|
32
|
+
}
|
|
21
33
|
export async function wipeStaleStore(db, dbFile) {
|
|
34
|
+
const held = heldFolders(db);
|
|
22
35
|
try {
|
|
23
36
|
const pending = db.prepare("SELECT count(*) AS n FROM sync_outbox").get();
|
|
24
37
|
if (Number(pending?.n ?? 0) > 0) {
|
|
@@ -31,4 +44,5 @@ export async function wipeStaleStore(db, dbFile) {
|
|
|
31
44
|
db.close();
|
|
32
45
|
for (const file of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`])
|
|
33
46
|
rmSync(file, { force: true });
|
|
47
|
+
return held;
|
|
34
48
|
}
|
package/dist/machine.sql
CHANGED
|
@@ -108,6 +108,10 @@ CREATE TABLE `projects` (
|
|
|
108
108
|
|
|
109
109
|
|
|
110
110
|
|
|
111
|
+
`repository_name` text,
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
111
115
|
|
|
112
116
|
|
|
113
117
|
|
|
@@ -529,6 +533,8 @@ CREATE UNIQUE INDEX `projects_init_idempotency_key_unique` ON `projects` (`init_
|
|
|
529
533
|
|
|
530
534
|
CREATE UNIQUE INDEX `projects_public_slug_unique` ON `projects` (`public_slug`) WHERE `public_slug` IS NOT NULL;
|
|
531
535
|
|
|
536
|
+
CREATE UNIQUE INDEX `projects_repository_name_unique` ON `projects` (`repository_name`) WHERE `repository_name` IS NOT NULL;
|
|
537
|
+
|
|
532
538
|
CREATE INDEX `projects_github_installation_repo_idx` ON `projects` (`github_installation_id`, `github_repo_full_name`);
|
|
533
539
|
|
|
534
540
|
CREATE INDEX `projects_harness_idx` ON `projects` (`harness_id`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
101
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/nustack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
"@types/node": "^22.19.20",
|
|
35
35
|
"typescript": "^5.7.2",
|
|
36
36
|
"vitest": "^4.1.11",
|
|
37
|
+
"@nurix/contracts": "3.0.1",
|
|
37
38
|
"@nurix/harness": "0.0.0",
|
|
38
39
|
"@nurix/nustack.studio": "0.0.0",
|
|
39
|
-
"@nurix/nustack.ui": "0.0.0"
|
|
40
|
-
"@nurix/contracts": "3.0.1"
|
|
40
|
+
"@nurix/nustack.ui": "0.0.0"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public"
|