@nurix/nustack 0.24.0 → 0.25.1
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/desktopAgent.js +1 -1
- package/dist/commands/doctor.js +8 -11
- package/dist/commands/obligationList.js +23 -49
- package/dist/commands/records.js +6 -88
- package/dist/commands/store.js +199 -0
- package/dist/commands/sync.js +2 -2
- package/dist/commands/toolkit.js +2 -2
- package/dist/lib/context/register.js +1 -1
- package/dist/lib/desktop-agents/checkout.js +1 -1
- package/dist/lib/desktop-agents/claudeRun.js +4 -4
- package/dist/lib/etna/reconcile.js +4 -4
- package/dist/lib/graph/library.js +1 -1
- package/dist/lib/harness/hookCheck.js +2 -8
- package/dist/lib/harness/storeWaivers.js +1 -4
- package/dist/lib/local_store/database.js +12 -92
- package/dist/lib/local_store/device_state.js +2 -2
- package/dist/lib/local_store/index.js +2 -2
- package/dist/lib/local_store/paths.js +0 -6
- package/dist/lib/local_store/routing.js +8 -44
- package/dist/lib/onboarding/folder_state.js +2 -2
- package/dist/lib/onboarding/init_fresh_folder.js +1 -1
- package/dist/lib/onboarding/manifest_writer.js +6 -6
- package/dist/lib/origin_guard.js +1 -1
- package/dist/lib/project_context.js +5 -5
- package/dist/lib/project_contract.js +2 -2
- package/dist/lib/records/drain.js +3 -153
- package/dist/lib/records/kinds.js +16 -0
- package/dist/lib/records/recordInput.js +1 -1
- package/dist/lib/records/recordsTable.js +119 -161
- package/dist/lib/records/refusalLog.js +7 -7
- package/dist/lib/sessions/brief.js +1 -1
- package/dist/lib/sessions/sessionEvents.js +5 -49
- package/dist/lib/store/open.js +136 -0
- package/dist/lib/store/wipe.js +34 -0
- package/dist/lib/sync/apply.js +113 -0
- package/dist/lib/sync/client.js +327 -0
- package/dist/lib/sync/endpoint.js +26 -0
- package/dist/lib/sync/manifest.js +80 -0
- package/dist/lib/sync/outbox.js +104 -0
- package/dist/machine.sql +934 -0
- package/dist/program.js +8 -6
- package/dist/records.schema.json +4 -0
- package/dist/spec_schema.json +5 -5
- package/dist/vendor/harness/rules/docsRoot.js +2 -2
- package/dist/vendor/harness/rules/index.js +1 -1
- package/package.json +5 -5
- package/dist/lib/local_store/migrations.js +0 -399
- package/dist/lib/records/door.js +0 -79
|
@@ -1,52 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
import { mkdirSecure } from "../secure_file.js";
|
|
5
|
-
import { stateDbPath } from "./paths.js";
|
|
6
|
-
import { migrateToLatest } from "./migrations.js";
|
|
7
|
-
let sqliteModulePromise;
|
|
8
|
-
async function loadSqlite() {
|
|
9
|
-
if (!sqliteModulePromise) {
|
|
10
|
-
sqliteModulePromise = importSqliteQuietly();
|
|
11
|
-
}
|
|
12
|
-
return sqliteModulePromise;
|
|
13
|
-
}
|
|
14
|
-
async function importSqliteQuietly() {
|
|
15
|
-
return withSuppressedSqliteWarning(() => import("node:sqlite"));
|
|
16
|
-
}
|
|
17
|
-
export async function withSuppressedSqliteWarning(fn) {
|
|
18
|
-
const original = process.emitWarning;
|
|
19
|
-
const filtered = (warning, ...args) => {
|
|
20
|
-
const name = warning instanceof Error
|
|
21
|
-
? warning.name
|
|
22
|
-
: typeof args[0] === "string"
|
|
23
|
-
? args[0]
|
|
24
|
-
: args[0]?.type;
|
|
25
|
-
const message = warning instanceof Error ? warning.message : String(warning);
|
|
26
|
-
if (name === "ExperimentalWarning" && /sqlite/i.test(message))
|
|
27
|
-
return;
|
|
28
|
-
original(warning, ...args);
|
|
29
|
-
};
|
|
30
|
-
process.emitWarning = filtered;
|
|
31
|
-
try {
|
|
32
|
-
return await fn();
|
|
33
|
-
}
|
|
34
|
-
finally {
|
|
35
|
-
process.emitWarning = original;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
export async function openLocalStore(options = {}) {
|
|
39
|
-
const dbFile = options.dbPath ?? stateDbPath();
|
|
40
|
-
await mkdirSecure(path.dirname(dbFile));
|
|
41
|
-
const db = await withSuppressedSqliteWarning(async () => {
|
|
42
|
-
const { DatabaseSync } = await loadSqlite();
|
|
43
|
-
return new DatabaseSync(dbFile);
|
|
44
|
-
});
|
|
45
|
-
db.exec("PRAGMA busy_timeout = 5000");
|
|
46
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
47
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
48
|
-
migrateToLatest(db);
|
|
49
|
-
await chmod(dbFile, 0o600).catch(() => { });
|
|
2
|
+
import { machineStorePath, openMachineDatabase, openMachineDatabaseReadOnly, openSqliteFile } from "../store/open.js";
|
|
3
|
+
function handleOf(db) {
|
|
50
4
|
return {
|
|
51
5
|
db,
|
|
52
6
|
close() {
|
|
@@ -54,58 +8,24 @@ export async function openLocalStore(options = {}) {
|
|
|
54
8
|
},
|
|
55
9
|
};
|
|
56
10
|
}
|
|
11
|
+
export async function openLocalStore(options = {}) {
|
|
12
|
+
return handleOf(await openMachineDatabase(options.dbPath ?? machineStorePath()));
|
|
13
|
+
}
|
|
57
14
|
export async function openLocalStoreReadOnly(options = {}) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
return null;
|
|
61
|
-
const db = await withSuppressedSqliteWarning(async () => {
|
|
62
|
-
const { DatabaseSync } = await loadSqlite();
|
|
63
|
-
return new DatabaseSync(dbFile, { readOnly: true });
|
|
64
|
-
});
|
|
65
|
-
db.exec("PRAGMA busy_timeout = 2000");
|
|
66
|
-
const row = db.prepare("PRAGMA user_version").get();
|
|
67
|
-
return {
|
|
68
|
-
db,
|
|
69
|
-
version: Number(row?.user_version ?? 0),
|
|
70
|
-
close() {
|
|
71
|
-
db.close();
|
|
72
|
-
},
|
|
73
|
-
};
|
|
15
|
+
const db = await openMachineDatabaseReadOnly(options.dbPath ?? machineStorePath());
|
|
16
|
+
return db ? handleOf(db) : null;
|
|
74
17
|
}
|
|
75
|
-
export async function readStoreVersion(dbFile =
|
|
18
|
+
export async function readStoreVersion(dbFile = machineStorePath()) {
|
|
76
19
|
if (!existsSync(dbFile))
|
|
77
20
|
return null;
|
|
78
|
-
let db;
|
|
79
|
-
try {
|
|
80
|
-
db = await withSuppressedSqliteWarning(async () => {
|
|
81
|
-
const { DatabaseSync } = await loadSqlite();
|
|
82
|
-
return new DatabaseSync(dbFile, { readOnly: true });
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
return headerUserVersion(dbFile);
|
|
87
|
-
}
|
|
88
21
|
try {
|
|
89
|
-
const
|
|
90
|
-
return typeof row?.user_version === "number" ? row.user_version : null;
|
|
91
|
-
}
|
|
92
|
-
catch {
|
|
93
|
-
return headerUserVersion(dbFile);
|
|
94
|
-
}
|
|
95
|
-
finally {
|
|
96
|
-
db.close();
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
async function headerUserVersion(dbFile) {
|
|
100
|
-
try {
|
|
101
|
-
const handle = await open(dbFile, "r");
|
|
22
|
+
const db = await openSqliteFile(dbFile, { readOnly: true });
|
|
102
23
|
try {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
return bytesRead === 64 ? header.readInt32BE(60) : null;
|
|
24
|
+
const row = db.prepare("PRAGMA user_version").get();
|
|
25
|
+
return typeof row?.user_version === "number" ? row.user_version : null;
|
|
106
26
|
}
|
|
107
27
|
finally {
|
|
108
|
-
|
|
28
|
+
db.close();
|
|
109
29
|
}
|
|
110
30
|
}
|
|
111
31
|
catch {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { cliVersion, storeStampOf } 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: storeStampOf(cliVersion()),
|
|
66
66
|
last_auth_refresh_at_ms: merged.lastAuthRefreshAtMs,
|
|
67
67
|
created_at_ms: merged.createdAtMs,
|
|
68
68
|
updated_at_ms: now,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { openLocalStore, withTransaction, } from "./database.js";
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
2
|
+
export { machineStorePath } from "../store/open.js";
|
|
3
|
+
export { nustackHomeDir, locksDir, cacheDir, recoveryDir, ensureNuStackLayout, } from "./paths.js";
|
|
4
4
|
export { INBOUND_APPLICATION_STATES, BINDING_STATUSES, LIVE_BINDING_STATUSES, INBOUND_APPLICATION_TRANSITIONS, isLegalApplicationTransition, } from "./sync_vocabulary.js";
|
|
5
5
|
export * from "./device_state.js";
|
|
6
6
|
export * from "./bindings.js";
|
|
@@ -4,9 +4,6 @@ import { mkdirSecure } from "../secure_file.js";
|
|
|
4
4
|
export function nustackHomeDir() {
|
|
5
5
|
return path.join(os.homedir(), ".nustack");
|
|
6
6
|
}
|
|
7
|
-
export function stateDbPath() {
|
|
8
|
-
return path.join(nustackHomeDir(), "state.db");
|
|
9
|
-
}
|
|
10
7
|
export function locksDir() {
|
|
11
8
|
return path.join(nustackHomeDir(), "locks");
|
|
12
9
|
}
|
|
@@ -28,9 +25,6 @@ export function sessionEventsDir() {
|
|
|
28
25
|
export function briefCachePath(projectId) {
|
|
29
26
|
return path.join(cacheDir(), "brief", `${projectId}.json`);
|
|
30
27
|
}
|
|
31
|
-
export function obligationsCachePath(projectId, planAnchor) {
|
|
32
|
-
return path.join(cacheDir(), "obligations", projectId, `${planAnchor}.json`);
|
|
33
|
-
}
|
|
34
28
|
export function memoryManifestPath(projectId) {
|
|
35
29
|
return path.join(cacheDir(), "memory-shipped", `${projectId}.json`);
|
|
36
30
|
}
|
|
@@ -13,8 +13,8 @@ export const LABEL_ROW_HANDLERS = [
|
|
|
13
13
|
export const LABEL_ROW_CHECK_RESULTS = ["pass", "fail", "budget", "unavailable", "latched"];
|
|
14
14
|
export const LABEL_ROW_ID_MAX = 128;
|
|
15
15
|
export const LABEL_ROW_PATH_MAX = 512;
|
|
16
|
-
export const LABEL_ROW_SYNC_STATES = ["pending", "synced", "refused", "local"];
|
|
17
16
|
export const GATED_PATH_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
|
17
|
+
export const UNRESOLVED_PROJECT_ID = "";
|
|
18
18
|
export class LabelRowRefusedError extends Error {
|
|
19
19
|
code = "LABEL_ROW_REFUSED";
|
|
20
20
|
constructor(message) {
|
|
@@ -38,7 +38,7 @@ function requireInteger(field, value, isSigned) {
|
|
|
38
38
|
export function clipLabelRowPath(path) {
|
|
39
39
|
return path.length <= LABEL_ROW_PATH_MAX ? path : `…${path.slice(path.length - (LABEL_ROW_PATH_MAX - 1))}`;
|
|
40
40
|
}
|
|
41
|
-
export function insertLabelRow(store, row,
|
|
41
|
+
export function insertLabelRow(store, row, isLocalOnly = false) {
|
|
42
42
|
requireIdentifier("id", row.id);
|
|
43
43
|
requireIdentifier("session_id", row.session_id);
|
|
44
44
|
requireIdentifier("agent_type", row.agent_type);
|
|
@@ -47,8 +47,6 @@ export function insertLabelRow(store, row, syncState = "pending") {
|
|
|
47
47
|
if (row.check_result !== null && !isOneOf(LABEL_ROW_CHECK_RESULTS, row.check_result)) {
|
|
48
48
|
throw new LabelRowRefusedError(`unknown check result "${String(row.check_result)}"`);
|
|
49
49
|
}
|
|
50
|
-
if (!isOneOf(LABEL_ROW_SYNC_STATES, syncState))
|
|
51
|
-
throw new LabelRowRefusedError(`unknown sync state "${String(syncState)}"`);
|
|
52
50
|
if (row.bypass_used !== 0 && row.bypass_used !== 1)
|
|
53
51
|
throw new LabelRowRefusedError("bypass_used must be 0 or 1");
|
|
54
52
|
if (row.path !== null && typeof row.path !== "string")
|
|
@@ -59,46 +57,12 @@ export function insertLabelRow(store, row, syncState = "pending") {
|
|
|
59
57
|
requireInteger("tokens_kept_out", row.tokens_kept_out, true);
|
|
60
58
|
requireInteger("created_at", row.created_at, false);
|
|
61
59
|
store.db
|
|
62
|
-
.prepare(`INSERT INTO
|
|
63
|
-
check_result,
|
|
60
|
+
.prepare(`INSERT INTO session_label_rows (id, session_id, project_id, agent_type, path, estimated_tokens, returned_tokens,
|
|
61
|
+
handler, check_result, is_bypass_used, latency_ms, tokens_kept_out, created_at)
|
|
64
62
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
65
|
-
.run(row.id, row.session_id, row.agent_type, row.path === null ? null : clipLabelRowPath(row.path), row.estimated_tokens, row.returned_tokens, row.handler, row.check_result, row.bypass_used, row.latency_ms, row.tokens_kept_out, row.created_at
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return {
|
|
69
|
-
id: record.id,
|
|
70
|
-
sessionId: record.session_id,
|
|
71
|
-
agentType: record.agent_type,
|
|
72
|
-
path: record.path,
|
|
73
|
-
estimatedTokens: record.estimated_tokens,
|
|
74
|
-
returnedTokens: record.returned_tokens,
|
|
75
|
-
handler: record.handler,
|
|
76
|
-
checkResult: record.check_result,
|
|
77
|
-
isBypassUsed: record.bypass_used === 1,
|
|
78
|
-
latencyMs: record.latency_ms,
|
|
79
|
-
tokensKeptOut: record.tokens_kept_out,
|
|
80
|
-
createdAt: record.created_at,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
export function listPendingLabelRows(store, limit) {
|
|
84
|
-
return store.db
|
|
85
|
-
.prepare("SELECT * FROM label_rows WHERE sync_state = 'pending' ORDER BY created_at, id LIMIT ?")
|
|
86
|
-
.all(limit);
|
|
87
|
-
}
|
|
88
|
-
function markPending(store, ids, state) {
|
|
89
|
-
if (ids.length === 0)
|
|
90
|
-
return 0;
|
|
91
|
-
const placeholders = ids.map(() => "?").join(", ");
|
|
92
|
-
const result = store.db
|
|
93
|
-
.prepare(`UPDATE label_rows SET sync_state = ? WHERE sync_state = 'pending' AND id IN (${placeholders})`)
|
|
94
|
-
.run(state, ...ids);
|
|
95
|
-
return Number(result.changes);
|
|
96
|
-
}
|
|
97
|
-
export function markLabelRowsSynced(store, ids) {
|
|
98
|
-
return markPending(store, ids, "synced");
|
|
99
|
-
}
|
|
100
|
-
export function markLabelRowsRefused(store, ids) {
|
|
101
|
-
return markPending(store, ids, "refused");
|
|
63
|
+
.run(row.id, row.session_id, UNRESOLVED_PROJECT_ID, row.agent_type, row.path === null ? null : clipLabelRowPath(row.path), row.estimated_tokens, row.returned_tokens, row.handler, row.check_result, row.bypass_used, row.latency_ms, row.tokens_kept_out, new Date(row.created_at).toISOString());
|
|
64
|
+
if (isLocalOnly)
|
|
65
|
+
store.db.prepare("DELETE FROM sync_outbox WHERE table_name = 'session_label_rows' AND row_key = ?").run(row.id);
|
|
102
66
|
}
|
|
103
67
|
export function isLatched(store, sessionId, jobKind) {
|
|
104
68
|
const row = store.db
|
|
@@ -153,7 +117,7 @@ export function recordBulkReadOutcome(store, transact, outcome, newId, now = Dat
|
|
|
153
117
|
latency_ms: outcome.latencyMs,
|
|
154
118
|
tokens_kept_out: -entry.returnedTokens,
|
|
155
119
|
created_at: now,
|
|
156
|
-
}, isDirect
|
|
120
|
+
}, isDirect);
|
|
157
121
|
if (outcome.result !== "pass")
|
|
158
122
|
gatePathOnce(store, outcome.sessionId, entry.realPath, now);
|
|
159
123
|
}
|
|
@@ -18,7 +18,7 @@ function significantEntries(dir) {
|
|
|
18
18
|
function readMarkerProjectId(root) {
|
|
19
19
|
const v2 = read(projectJsonPath(root));
|
|
20
20
|
if (v2 && v2.schemaVersion === 2) {
|
|
21
|
-
const id = trimmedField(v2.
|
|
21
|
+
const id = trimmedField(v2.projectId);
|
|
22
22
|
if (id)
|
|
23
23
|
return id;
|
|
24
24
|
}
|
|
@@ -42,7 +42,7 @@ function isNestedUnderProject(root) {
|
|
|
42
42
|
let dir = path.dirname(root);
|
|
43
43
|
while (dir && dir !== path.dirname(dir)) {
|
|
44
44
|
const nustack = path.join(dir, ".nustack");
|
|
45
|
-
if (existsSync(path.join(nustack, "
|
|
45
|
+
if (existsSync(path.join(nustack, "project.json")) || existsSync(path.join(nustack, "app.json")))
|
|
46
46
|
return true;
|
|
47
47
|
dir = path.dirname(dir);
|
|
48
48
|
}
|
|
@@ -9,7 +9,7 @@ import { classifyFolder } from "./folder_state.js";
|
|
|
9
9
|
import { initGitRepository } from "./git_init.js";
|
|
10
10
|
import { composeOnboardingCommit, resolveHandoffTargets, } from "./handoff_apply.js";
|
|
11
11
|
import { runRepositoryOnboarding, } from "./binding.js";
|
|
12
|
-
export const MANAGED_ONBOARDING_FILES = [".nustack/
|
|
12
|
+
export const MANAGED_ONBOARDING_FILES = [".nustack/project.json", "nustack.service.json"];
|
|
13
13
|
export async function initFreshFolder(ctx, options) {
|
|
14
14
|
const state = classifyFolder(ctx.cwd);
|
|
15
15
|
if (state.class === "git_worktree") {
|
|
@@ -8,7 +8,7 @@ const CURRENT_DOCUMENT_PROTOCOL_VERSION = 1;
|
|
|
8
8
|
function buildProjectManifest(identity, existing) {
|
|
9
9
|
const owned = {
|
|
10
10
|
schemaVersion: 2,
|
|
11
|
-
|
|
11
|
+
projectId: identity.projectId,
|
|
12
12
|
organizationId: identity.organizationId,
|
|
13
13
|
bindingProtocolVersion: identity.bindingProtocolVersion ?? CURRENT_BINDING_PROTOCOL_VERSION,
|
|
14
14
|
documentProtocolVersion: identity.documentProtocolVersion ?? CURRENT_DOCUMENT_PROTOCOL_VERSION,
|
|
@@ -66,7 +66,7 @@ export async function writeProjectManifests(root, identity, hooks = {}) {
|
|
|
66
66
|
}
|
|
67
67
|
const nextService = {
|
|
68
68
|
...serviceManifest,
|
|
69
|
-
|
|
69
|
+
nustackProject: { v: 2, projectId: identity.projectId, organizationId: identity.organizationId },
|
|
70
70
|
toolkit: { name: identity.toolkit.name, version: identity.toolkit.version },
|
|
71
71
|
};
|
|
72
72
|
delete nextService.nustackApp;
|
|
@@ -137,7 +137,7 @@ async function readProjectJson(root) {
|
|
|
137
137
|
return null;
|
|
138
138
|
return {
|
|
139
139
|
schemaVersion: 2,
|
|
140
|
-
|
|
140
|
+
projectId: marker.projectId,
|
|
141
141
|
organizationId: marker.organizationId,
|
|
142
142
|
bindingProtocolVersion: typeof raw.bindingProtocolVersion === "number" ? raw.bindingProtocolVersion : 1,
|
|
143
143
|
documentProtocolVersion: typeof raw.documentProtocolVersion === "number" ? raw.documentProtocolVersion : 1,
|
|
@@ -159,10 +159,10 @@ async function readServiceBlocks(root) {
|
|
|
159
159
|
return { binding: null, toolkit: null };
|
|
160
160
|
try {
|
|
161
161
|
const manifest = JSON.parse(await readFile(filePath, "utf8"));
|
|
162
|
-
const v2 = manifest.
|
|
162
|
+
const v2 = manifest.nustackProject;
|
|
163
163
|
const v1 = manifest.nustackApp;
|
|
164
|
-
const binding = typeof v2?.
|
|
165
|
-
? { projectId: v2.
|
|
164
|
+
const binding = typeof v2?.projectId === "string" && typeof v2?.organizationId === "string"
|
|
165
|
+
? { projectId: v2.projectId, organizationId: v2.organizationId }
|
|
166
166
|
: typeof v1?.appId === "string" && typeof v1?.workspaceId === "string"
|
|
167
167
|
? { projectId: v1.appId, organizationId: v1.workspaceId }
|
|
168
168
|
: null;
|
package/dist/lib/origin_guard.js
CHANGED
|
@@ -25,7 +25,7 @@ export function originMismatchError(origin, credentialOrigin, credentialKind) {
|
|
|
25
25
|
: credentialKind === "none"
|
|
26
26
|
? "this machine is not signed in to it, and a sign-in never opens for an origin a repository names"
|
|
27
27
|
: `this machine's sign-in is for ${credentialOrigin ?? "another origin"}`;
|
|
28
|
-
return new HarnessError(HARNESS_CODES.ORIGIN_MISMATCH, `This repository's .nustack/
|
|
28
|
+
return new HarnessError(HARNESS_CODES.ORIGIN_MISMATCH, `This repository's .nustack/project.json names ${origin} as its NuStack origin, but ${held}. ` +
|
|
29
29
|
`To use ${origin}, state it outside the repository: \`nustack login --nustack-url ${origin}\`, or set NUSTACK_URL=${origin}.`);
|
|
30
30
|
}
|
|
31
31
|
export function refusedContractOrigin(credentialOrigin, origin) {
|
|
@@ -4,9 +4,9 @@ import { existsSync } from "node:fs";
|
|
|
4
4
|
import { readManagedValues } from "./env_file.js";
|
|
5
5
|
import { manifestPath } from "./manifest.js";
|
|
6
6
|
import { assertSafeOpaqueId } from "./guards.js";
|
|
7
|
-
const NO_PROJECT_MESSAGE = "No Project id — run inside a bound repository (`.nustack/
|
|
7
|
+
const NO_PROJECT_MESSAGE = "No Project id — run inside a bound repository (`.nustack/project.json`), pass `--project <id>`, add an `nustackProject.projectId` to nustack.service.json, or set NUSTACK_PROJECT_ID in the managed .env block.";
|
|
8
8
|
export function projectJsonPath(cwd) {
|
|
9
|
-
return path.join(cwd, ".nustack", "
|
|
9
|
+
return path.join(cwd, ".nustack", "project.json");
|
|
10
10
|
}
|
|
11
11
|
export function legacyAppJsonPath(cwd) {
|
|
12
12
|
return path.join(cwd, ".nustack", "app.json");
|
|
@@ -17,7 +17,7 @@ export async function readBindingMarker(cwd) {
|
|
|
17
17
|
try {
|
|
18
18
|
const marker = JSON.parse(await readFile(v2Path, "utf8"));
|
|
19
19
|
if (marker.schemaVersion === 2) {
|
|
20
|
-
const projectId = trimmed(marker.
|
|
20
|
+
const projectId = trimmed(marker.projectId);
|
|
21
21
|
if (projectId) {
|
|
22
22
|
return { projectId, organizationId: trimmed(marker.organizationId), version: 2 };
|
|
23
23
|
}
|
|
@@ -54,7 +54,7 @@ async function readManifestProjectId(cwd) {
|
|
|
54
54
|
return null;
|
|
55
55
|
try {
|
|
56
56
|
const manifest = JSON.parse(await readFile(filePath, "utf8"));
|
|
57
|
-
return trimmed(manifest.
|
|
57
|
+
return trimmed(manifest.nustackProject?.projectId) ?? trimmed(manifest.nustackApp?.appId);
|
|
58
58
|
}
|
|
59
59
|
catch {
|
|
60
60
|
return null;
|
|
@@ -62,7 +62,7 @@ async function readManifestProjectId(cwd) {
|
|
|
62
62
|
}
|
|
63
63
|
async function readEnvProjectId(cwd) {
|
|
64
64
|
const managed = await readManagedValues(path.join(cwd, ".env"));
|
|
65
|
-
const projectId = managed.
|
|
65
|
+
const projectId = managed.NUSTACK_PROJECT_ID?.trim();
|
|
66
66
|
return projectId ? projectId : null;
|
|
67
67
|
}
|
|
68
68
|
export async function resolveProjectId(cwd, options) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
export const CONTRACT_PATH = ".nustack/
|
|
4
|
+
export const CONTRACT_PATH = ".nustack/project.json";
|
|
5
5
|
export const LEGACY_HARNESS_MANIFEST_PATH = ".claude/harness.json";
|
|
6
6
|
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
7
7
|
function parseObject(text) {
|
|
@@ -59,7 +59,7 @@ export function parseProjectContract(contractText, legacyText) {
|
|
|
59
59
|
const contractDoor = doorOf(contract);
|
|
60
60
|
const legacyDoor = doorOf(legacyMount(legacy));
|
|
61
61
|
return {
|
|
62
|
-
projectId: contract === null ? null : nonEmpty(contract.
|
|
62
|
+
projectId: contract === null ? null : nonEmpty(contract.projectId),
|
|
63
63
|
harness: contract === null ? null : harnessOf(contract.harness),
|
|
64
64
|
door: contractDoor !== undefined ? contractDoor : legacyDoor,
|
|
65
65
|
doorSource: contractDoor !== undefined ? "contract" : legacyDoor !== undefined ? "legacy" : null,
|
|
@@ -1,160 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { closeSync,
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, utimesSync, writeSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { ensureAccessToken } from "../auth_grant.js";
|
|
6
|
-
import { isOriginMismatch } from "../origin_guard.js";
|
|
7
5
|
import { recordsDrainLockPath } from "../local_store/paths.js";
|
|
8
|
-
import {
|
|
9
|
-
import { RECORDS_BATCH_CAP, postRecords, resolveDoor, retryDelayMs } from "./door.js";
|
|
10
|
-
import { DEVICE_TOKEN_VARIABLE, DRAIN_CHILD_VARIABLE, NO_DETACHED_DRAIN_VARIABLE, isFlagSet, readVariable } from "./environment.js";
|
|
11
|
-
import { envelopeOfStored } from "./recordInput.js";
|
|
12
|
-
import { validateEnvelope } from "./recordValidator.js";
|
|
13
|
-
import { OBLIGATION_AGENT_OWNED, markBackoff, markDeferred, markRefused, markSynced, pruneSynced, rekeyRecord, selectDueRecords, } from "./recordsTable.js";
|
|
6
|
+
import { DRAIN_CHILD_VARIABLE, NO_DETACHED_DRAIN_VARIABLE, isFlagSet } from "./environment.js";
|
|
14
7
|
export const DRAIN_LOCK_STALE_MS = 120_000;
|
|
15
|
-
const MAX_PASSES = 50;
|
|
16
|
-
const DEVICE_PROJECTION_FIELDS = ["origin", "type", "subject", "state", "outcome", "ownerAgentDefinitionId"];
|
|
17
|
-
function payloadToSend(row) {
|
|
18
|
-
const payload = JSON.parse(row.payloadJson);
|
|
19
|
-
if (row.placement !== "device" || row.kind !== "obligation")
|
|
20
|
-
return payload;
|
|
21
|
-
const projection = {};
|
|
22
|
-
for (const field of DEVICE_PROJECTION_FIELDS)
|
|
23
|
-
if (payload[field] !== undefined)
|
|
24
|
-
projection[field] = payload[field];
|
|
25
|
-
return projection;
|
|
26
|
-
}
|
|
27
|
-
async function targetOf(store, row, globals, doors) {
|
|
28
|
-
if (row.doorUrl !== null && row.projectId !== null)
|
|
29
|
-
return { row, doorUrl: row.doorUrl, projectId: row.projectId };
|
|
30
|
-
if (row.repositoryRoot !== null && existsSync(row.repositoryRoot)) {
|
|
31
|
-
let door = doors.get(row.repositoryRoot);
|
|
32
|
-
if (!door) {
|
|
33
|
-
door = await resolveDoor(row.repositoryRoot, globals);
|
|
34
|
-
doors.set(row.repositoryRoot, door);
|
|
35
|
-
}
|
|
36
|
-
if (door.kind !== "door")
|
|
37
|
-
return null;
|
|
38
|
-
if (row.projectId !== null)
|
|
39
|
-
return { row, doorUrl: door.doorUrl, projectId: row.projectId };
|
|
40
|
-
const rekeyed = rekeyRecord(store, row, door.projectId);
|
|
41
|
-
return rekeyed ? { row: rekeyed, doorUrl: door.doorUrl, projectId: door.projectId } : "gone";
|
|
42
|
-
}
|
|
43
|
-
if (row.projectId !== null)
|
|
44
|
-
return { row, doorUrl: resolveNuStackUrl(globals), projectId: row.projectId };
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
function isRowRefusal(outcome) {
|
|
48
|
-
return outcome.status === 400 || (outcome.status === 403 && outcome.code === OBLIGATION_AGENT_OWNED);
|
|
49
|
-
}
|
|
50
|
-
async function drainPass(store, globals, result, shouldIgnoreBackoff) {
|
|
51
|
-
const now = Date.now();
|
|
52
|
-
const deviceToken = readVariable(DEVICE_TOKEN_VARIABLE);
|
|
53
|
-
const due = selectDueRecords(store, shouldIgnoreBackoff ? Number.MAX_SAFE_INTEGER : now, deviceToken !== null);
|
|
54
|
-
if (due.length === 0)
|
|
55
|
-
return 0;
|
|
56
|
-
const doors = new Map();
|
|
57
|
-
const groups = new Map();
|
|
58
|
-
for (const selected of due) {
|
|
59
|
-
const target = await targetOf(store, selected, globals, doors);
|
|
60
|
-
if (target === "gone")
|
|
61
|
-
continue;
|
|
62
|
-
if (target === null) {
|
|
63
|
-
markDeferred(store, selected, now + retryDelayMs(Math.max(1, selected.attempts)));
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
const envelope = envelopeOfStored(target.row, target.projectId, payloadToSend(target.row));
|
|
67
|
-
const verdict = validateEnvelope(envelope);
|
|
68
|
-
if (!verdict.isValid) {
|
|
69
|
-
const error = `VALIDATION_FAILED: ${verdict.errors[0] ?? "the row fails the records schema"}`;
|
|
70
|
-
if (markRefused(store, target.row, error)) {
|
|
71
|
-
result.refused += 1;
|
|
72
|
-
result.lastError = error;
|
|
73
|
-
}
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
const group = `${target.row.credential ?? ""}\u0000${target.doorUrl}\u0000${target.projectId}\u0000${target.row.kind}`;
|
|
77
|
-
const list = groups.get(group) ?? [];
|
|
78
|
-
list.push({ row: target.row, envelope, doorUrl: target.doorUrl, projectId: target.projectId });
|
|
79
|
-
groups.set(group, list);
|
|
80
|
-
}
|
|
81
|
-
let personToken;
|
|
82
|
-
for (const list of groups.values()) {
|
|
83
|
-
const first = list[0];
|
|
84
|
-
let bearer;
|
|
85
|
-
if (first.row.credential === "device")
|
|
86
|
-
bearer = deviceToken;
|
|
87
|
-
else {
|
|
88
|
-
if (personToken === undefined) {
|
|
89
|
-
personToken = await ensureAccessToken().then((auth) => auth?.accessToken ?? null, (error) => {
|
|
90
|
-
if (isOriginMismatch(error))
|
|
91
|
-
result.originRefusal = error.message;
|
|
92
|
-
return null;
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
bearer = personToken;
|
|
96
|
-
}
|
|
97
|
-
for (let start = 0; start < list.length; start += RECORDS_BATCH_CAP) {
|
|
98
|
-
const batch = list.slice(start, start + RECORDS_BATCH_CAP);
|
|
99
|
-
if (bearer === null) {
|
|
100
|
-
backoffAll(store, batch, result.originRefusal ?? "this device is signed out", result);
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
await sendBatch(store, batch, first.row.kind, bearer, result);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return due.length;
|
|
107
|
-
}
|
|
108
|
-
function backoffAll(store, batch, error, result) {
|
|
109
|
-
const now = Date.now();
|
|
110
|
-
for (const item of batch)
|
|
111
|
-
markBackoff(store, item.row, error, now + retryDelayMs(item.row.attempts + 1));
|
|
112
|
-
result.lastError = error;
|
|
113
|
-
}
|
|
114
|
-
async function sendBatch(store, batch, kind, bearer, result) {
|
|
115
|
-
const { doorUrl, projectId } = batch[0];
|
|
116
|
-
const outcome = await postRecords(doorUrl, projectId, { kind, rows: batch.map((item) => item.envelope) }, bearer);
|
|
117
|
-
if (outcome.ok) {
|
|
118
|
-
for (const item of batch)
|
|
119
|
-
markSynced(store, item.row);
|
|
120
|
-
result.sent += batch.length;
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (!isRowRefusal(outcome)) {
|
|
124
|
-
backoffAll(store, batch, outcome.message, result);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
if (batch.length === 1) {
|
|
128
|
-
if (markRefused(store, batch[0].row, outcome.message))
|
|
129
|
-
result.refused += 1;
|
|
130
|
-
result.lastError = outcome.message;
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
for (const item of batch) {
|
|
134
|
-
const alone = await postRecords(doorUrl, projectId, { kind, rows: [item.envelope] }, bearer);
|
|
135
|
-
if (alone.ok) {
|
|
136
|
-
markSynced(store, item.row);
|
|
137
|
-
result.sent += 1;
|
|
138
|
-
}
|
|
139
|
-
else if (isRowRefusal(alone)) {
|
|
140
|
-
if (markRefused(store, item.row, alone.message))
|
|
141
|
-
result.refused += 1;
|
|
142
|
-
result.lastError = alone.message;
|
|
143
|
-
}
|
|
144
|
-
else
|
|
145
|
-
backoffAll(store, [item], alone.message, result);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
export async function drainRecords(store, globals, options = {}) {
|
|
149
|
-
const result = { sent: 0, refused: 0, lastError: null, originRefusal: null };
|
|
150
|
-
pruneSynced(store, Date.now());
|
|
151
|
-
for (let pass = 0; pass < MAX_PASSES; pass += 1) {
|
|
152
|
-
options.onPass?.();
|
|
153
|
-
if ((await drainPass(store, globals, result, pass === 0 && options.isForeground === true)) === 0)
|
|
154
|
-
break;
|
|
155
|
-
}
|
|
156
|
-
return result;
|
|
157
|
-
}
|
|
158
8
|
function tryCreateLock(lockPath) {
|
|
159
9
|
try {
|
|
160
10
|
const fd = openSync(lockPath, "wx", 0o600);
|
|
@@ -211,7 +61,7 @@ export function startDetachedDrain(options = {}) {
|
|
|
211
61
|
try {
|
|
212
62
|
if (isFlagSet(NO_DETACHED_DRAIN_VARIABLE) || isFlagSet(DRAIN_CHILD_VARIABLE))
|
|
213
63
|
return;
|
|
214
|
-
const args = [cliEntryPath(), ...(options.nustackUrl ? ["--nustack-url", options.nustackUrl] : []), "
|
|
64
|
+
const args = [cliEntryPath(), ...(options.nustackUrl ? ["--nustack-url", options.nustackUrl] : []), "store", "sync", "--quiet"];
|
|
215
65
|
const child = spawn(process.execPath, args, {
|
|
216
66
|
detached: true,
|
|
217
67
|
stdio: "ignore",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const RECORD_KINDS = ["obligation", "gate_event", "changelog", "memory", "backlog", "proposal", "triage"];
|
|
2
|
+
export const LEGACY_KIND_TRANSLATION = { "backlog.remove": "backlog", bookkeeping: "obligation" };
|
|
3
|
+
export const RECORDS_BATCH_CAP = 200;
|
|
4
|
+
export const RECORD_SCHEMA_VERSION = 1;
|
|
5
|
+
export function isRecordKind(value) {
|
|
6
|
+
return typeof value === "string" && RECORD_KINDS.includes(value);
|
|
7
|
+
}
|
|
8
|
+
export function isLegacyKind(value) {
|
|
9
|
+
return typeof value === "string" && Object.hasOwn(LEGACY_KIND_TRANSLATION, value);
|
|
10
|
+
}
|
|
11
|
+
export function isEmitKind(value) {
|
|
12
|
+
return isRecordKind(value) || isLegacyKind(value);
|
|
13
|
+
}
|
|
14
|
+
export function translatedKindOf(kind) {
|
|
15
|
+
return isLegacyKind(kind) ? LEGACY_KIND_TRANSLATION[kind] : kind;
|
|
16
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { RECORD_SCHEMA_VERSION, isLegacyKind, translatedKindOf } from "./
|
|
2
|
+
import { RECORD_SCHEMA_VERSION, isLegacyKind, translatedKindOf } from "./kinds.js";
|
|
3
3
|
import { idempotencyKeyOf, naturalKeyOf } from "./naturalKey.js";
|
|
4
4
|
import { validateEnvelope } from "./recordValidator.js";
|
|
5
5
|
import { scanPayload } from "./secretScan.js";
|