@nurix/nustack 0.23.3 → 0.23.5
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/recordAliases.js +183 -0
- package/dist/commands/records.js +192 -94
- package/dist/index.js +6 -3
- package/dist/lib/desktop_settings.js +23 -0
- package/dist/lib/local_store/database.js +1 -1
- package/dist/lib/local_store/migrations.js +79 -0
- package/dist/lib/local_store/paths.js +6 -0
- package/dist/lib/records/door.js +23 -174
- package/dist/lib/records/drain.js +219 -0
- package/dist/lib/records/environment.js +25 -0
- package/dist/lib/records/naturalKey.js +33 -0
- package/dist/lib/records/recordInput.js +145 -0
- package/dist/lib/records/recordValidator.js +38 -0
- package/dist/lib/records/recordsTable.js +287 -0
- package/dist/lib/records/refusalLog.js +56 -0
- package/dist/lib/records/secretScan.js +45 -0
- package/package.json +6 -5
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { RECORD_SCHEMA_VERSION, isLegacyKind, translatedKindOf } from "./door.js";
|
|
3
|
+
import { idempotencyKeyOf, naturalKeyOf } from "./naturalKey.js";
|
|
4
|
+
import { validateEnvelope } from "./recordValidator.js";
|
|
5
|
+
import { scanPayload } from "./secretScan.js";
|
|
6
|
+
export const LEGACY_BOOKKEEPING_ORIGIN = "session:legacy-bookkeeping";
|
|
7
|
+
export const UNBOUND_PROJECT_ID = "unbound";
|
|
8
|
+
export const UNKNOWN_KIND_MESSAGE = "--kind must be obligation, gate_event, changelog, memory, backlog, proposal or triage";
|
|
9
|
+
const INPUT_ROW_KEYS = new Set(["payload", "sessionId", "planAnchor", "occurredAt", "naturalKey"]);
|
|
10
|
+
const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
function isEnvelope(value) {
|
|
12
|
+
if (!isObject(value))
|
|
13
|
+
return false;
|
|
14
|
+
return Array.isArray(value.rows) && typeof value.anchor !== "string" && !isObject(value.payload);
|
|
15
|
+
}
|
|
16
|
+
export function parseRowsText(raw) {
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(parsed))
|
|
25
|
+
return { rows: parsed };
|
|
26
|
+
if (isEnvelope(parsed))
|
|
27
|
+
return { rows: parsed.rows, kind: parsed.kind };
|
|
28
|
+
return { rows: [parsed] };
|
|
29
|
+
}
|
|
30
|
+
export function isLegacyRow(row) {
|
|
31
|
+
return isObject(row) && typeof row.anchor === "string" && row.anchor.trim() !== "" && !isObject(row.payload);
|
|
32
|
+
}
|
|
33
|
+
export function translateLegacyRow(kind, row) {
|
|
34
|
+
const { idempotencyKey: _dropped, ...rest } = row;
|
|
35
|
+
let planAnchor;
|
|
36
|
+
if (typeof rest.planAnchor === "string") {
|
|
37
|
+
planAnchor = rest.planAnchor;
|
|
38
|
+
delete rest.planAnchor;
|
|
39
|
+
}
|
|
40
|
+
const withPlan = (value) => (planAnchor !== undefined ? { ...value, planAnchor } : value);
|
|
41
|
+
if (kind === "bookkeeping") {
|
|
42
|
+
const payload = {
|
|
43
|
+
origin: planAnchor !== undefined ? `plan:${planAnchor}` : LEGACY_BOOKKEEPING_ORIGIN,
|
|
44
|
+
type: "documentation",
|
|
45
|
+
subject: row.anchor,
|
|
46
|
+
...(typeof row.text === "string" ? { claim: row.text } : {}),
|
|
47
|
+
state: "open",
|
|
48
|
+
};
|
|
49
|
+
return withPlan({ kind: "obligation", payload });
|
|
50
|
+
}
|
|
51
|
+
if (kind === "backlog.remove")
|
|
52
|
+
return withPlan({ kind: "backlog", payload: { ...rest, state: "removed" } });
|
|
53
|
+
return withPlan({ kind: "backlog", payload: rest });
|
|
54
|
+
}
|
|
55
|
+
export function translateRow(kind, row, index) {
|
|
56
|
+
const at = `row ${index}`;
|
|
57
|
+
if (isObject(row) && isObject(row.payload)) {
|
|
58
|
+
const extra = Object.keys(row).filter((key) => !INPUT_ROW_KEYS.has(key));
|
|
59
|
+
if (extra.length > 0)
|
|
60
|
+
return { ok: false, message: `${at}: an input row carries no ${extra.map((key) => JSON.stringify(key)).join(", ")}` };
|
|
61
|
+
const input = { ...row };
|
|
62
|
+
if (kind === "backlog.remove")
|
|
63
|
+
input.payload = { ...input.payload, state: "removed" };
|
|
64
|
+
return { ok: true, kind: translatedKindOf(kind), input };
|
|
65
|
+
}
|
|
66
|
+
if (isLegacyRow(row)) {
|
|
67
|
+
if (kind !== "backlog" && !isLegacyKind(kind)) {
|
|
68
|
+
return { ok: false, message: `${at}: a ${kind} row is an input row {payload, sessionId?, planAnchor?, occurredAt?}` };
|
|
69
|
+
}
|
|
70
|
+
const translated = translateLegacyRow(kind, row);
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
kind: translated.kind,
|
|
74
|
+
input: { payload: translated.payload, ...(translated.planAnchor !== undefined ? { planAnchor: translated.planAnchor } : {}) },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { ok: false, message: `${at}: each row is an input row {payload, …} or a legacy row with a string anchor` };
|
|
78
|
+
}
|
|
79
|
+
export function placementFor(kind, payload, settings) {
|
|
80
|
+
if (kind === "memory")
|
|
81
|
+
return settings.placement.memory;
|
|
82
|
+
if (kind === "obligation" && payload.type === "security")
|
|
83
|
+
return settings.placement.securityFindings;
|
|
84
|
+
return "server";
|
|
85
|
+
}
|
|
86
|
+
export function assembleRecord(kind, input, context, index) {
|
|
87
|
+
const at = `row ${index}`;
|
|
88
|
+
let payload = { ...input.payload };
|
|
89
|
+
if (kind === "gate_event" && payload.eventId === undefined)
|
|
90
|
+
payload = { eventId: randomUUID(), ...payload };
|
|
91
|
+
const scanned = scanPayload(payload);
|
|
92
|
+
payload = scanned.payload;
|
|
93
|
+
const naturalKey = naturalKeyOf(kind, payload);
|
|
94
|
+
if (input.naturalKey !== undefined && input.naturalKey !== naturalKey) {
|
|
95
|
+
return { ok: false, message: `${at}: naturalKey must equal naturalKeyOf(kind, payload)` };
|
|
96
|
+
}
|
|
97
|
+
const sessionId = input.sessionId !== undefined ? input.sessionId : context.sessionId;
|
|
98
|
+
const planAnchor = input.planAnchor !== undefined ? input.planAnchor : context.planAnchor;
|
|
99
|
+
const occurredAt = input.occurredAt !== undefined ? input.occurredAt : context.now.toISOString();
|
|
100
|
+
const envelope = {
|
|
101
|
+
kind,
|
|
102
|
+
schemaVersion: RECORD_SCHEMA_VERSION,
|
|
103
|
+
projectId: context.projectId ?? UNBOUND_PROJECT_ID,
|
|
104
|
+
...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}),
|
|
105
|
+
...(planAnchor !== null && planAnchor !== undefined ? { planAnchor } : {}),
|
|
106
|
+
naturalKey,
|
|
107
|
+
payload,
|
|
108
|
+
occurredAt,
|
|
109
|
+
};
|
|
110
|
+
const verdict = validateEnvelope(envelope);
|
|
111
|
+
if (!verdict.isValid)
|
|
112
|
+
return { ok: false, message: `${at}: ${verdict.errors.slice(0, 3).join("; ")}` };
|
|
113
|
+
const valid = envelope;
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
envelope: valid,
|
|
117
|
+
flags: scanned.flags,
|
|
118
|
+
write: {
|
|
119
|
+
kind,
|
|
120
|
+
schemaVersion: RECORD_SCHEMA_VERSION,
|
|
121
|
+
projectId: context.projectId,
|
|
122
|
+
sessionId: valid.sessionId ?? null,
|
|
123
|
+
planAnchor: valid.planAnchor ?? null,
|
|
124
|
+
naturalKey,
|
|
125
|
+
idempotencyKey: idempotencyKeyOf(context.projectId ?? context.root, kind, naturalKey),
|
|
126
|
+
payload,
|
|
127
|
+
createdAt: Date.parse(valid.occurredAt),
|
|
128
|
+
repositoryRoot: context.root,
|
|
129
|
+
placement: placementFor(kind, payload, context.settings),
|
|
130
|
+
credential: context.credential,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export function envelopeOfStored(row, projectId, payload) {
|
|
135
|
+
return {
|
|
136
|
+
kind: row.kind,
|
|
137
|
+
schemaVersion: RECORD_SCHEMA_VERSION,
|
|
138
|
+
projectId,
|
|
139
|
+
...(row.sessionId !== null ? { sessionId: row.sessionId } : {}),
|
|
140
|
+
...(row.planAnchor !== null ? { planAnchor: row.planAnchor } : {}),
|
|
141
|
+
naturalKey: row.naturalKey,
|
|
142
|
+
payload,
|
|
143
|
+
occurredAt: new Date(row.createdAt).toISOString(),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { Ajv } from "ajv";
|
|
3
|
+
import addFormatsImport from "ajv-formats";
|
|
4
|
+
const addFormats = addFormatsImport;
|
|
5
|
+
let compiled = null;
|
|
6
|
+
function compile() {
|
|
7
|
+
if (compiled)
|
|
8
|
+
return compiled;
|
|
9
|
+
const twin = JSON.parse(readFileSync(new URL("../../records.schema.json", import.meta.url), "utf8"));
|
|
10
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
11
|
+
addFormats(ajv);
|
|
12
|
+
ajv.addSchema(twin);
|
|
13
|
+
const envelope = ajv.getSchema(`${twin.$id}#/definitions/RecordEnvelope`);
|
|
14
|
+
if (!envelope)
|
|
15
|
+
throw new Error("the records twin defines no RecordEnvelope");
|
|
16
|
+
const byKind = new Map();
|
|
17
|
+
(twin.definitions.RecordEnvelope.oneOf ?? []).forEach((branch, index) => {
|
|
18
|
+
const kind = branch.properties?.kind?.const;
|
|
19
|
+
const validate = ajv.getSchema(`${twin.$id}#/definitions/RecordEnvelope/oneOf/${index}`);
|
|
20
|
+
if (kind && validate)
|
|
21
|
+
byKind.set(kind, validate);
|
|
22
|
+
});
|
|
23
|
+
compiled = { envelope, byKind };
|
|
24
|
+
return compiled;
|
|
25
|
+
}
|
|
26
|
+
function messagesOf(validate) {
|
|
27
|
+
return (validate.errors ?? []).map((error) => `${error.instancePath || "/"} ${error.message ?? "invalid"}`);
|
|
28
|
+
}
|
|
29
|
+
export function validateEnvelope(envelope) {
|
|
30
|
+
const { envelope: validate, byKind } = compile();
|
|
31
|
+
if (validate(envelope))
|
|
32
|
+
return { isValid: true, errors: [] };
|
|
33
|
+
const kind = envelope?.kind;
|
|
34
|
+
const branch = typeof kind === "string" ? byKind.get(kind) : undefined;
|
|
35
|
+
if (branch && !branch(envelope))
|
|
36
|
+
return { isValid: false, errors: messagesOf(branch) };
|
|
37
|
+
return { isValid: false, errors: messagesOf(validate) };
|
|
38
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { withTransaction } from "../local_store/database.js";
|
|
3
|
+
import { readMountDoor } from "./door.js";
|
|
4
|
+
import { AGENT_DEFINITION_ID_VARIABLE, readVariable } from "./environment.js";
|
|
5
|
+
export const SYNC_STATES = ["pending", "synced", "refused", "local"];
|
|
6
|
+
export const PLACEMENTS = ["device", "server"];
|
|
7
|
+
export const CREDENTIALS = ["device"];
|
|
8
|
+
export const SYNCED_RETENTION_DAYS = 30;
|
|
9
|
+
export const RECORDS_LIST_LIMIT = 200;
|
|
10
|
+
export const OBLIGATION_AGENT_OWNED = "OBLIGATION_AGENT_OWNED";
|
|
11
|
+
function recordOf(row) {
|
|
12
|
+
return {
|
|
13
|
+
id: row.id,
|
|
14
|
+
kind: row.kind,
|
|
15
|
+
schemaVersion: row.schema_version,
|
|
16
|
+
projectId: row.project_id ?? null,
|
|
17
|
+
sessionId: row.session_id ?? null,
|
|
18
|
+
planAnchor: row.plan_anchor ?? null,
|
|
19
|
+
naturalKey: row.natural_key,
|
|
20
|
+
idempotencyKey: row.idempotency_key,
|
|
21
|
+
payloadJson: row.payload_json,
|
|
22
|
+
syncState: row.sync_state,
|
|
23
|
+
attempts: row.attempts,
|
|
24
|
+
nextAttemptAt: row.next_attempt_at ?? null,
|
|
25
|
+
lastError: row.last_error ?? null,
|
|
26
|
+
createdAt: row.created_at,
|
|
27
|
+
repositoryRoot: row.repository_root ?? null,
|
|
28
|
+
doorUrl: row.door_url ?? null,
|
|
29
|
+
placement: row.placement ?? null,
|
|
30
|
+
credential: row.credential ?? null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function canonicalJson(value) {
|
|
34
|
+
if (Array.isArray(value))
|
|
35
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
36
|
+
if (value && typeof value === "object") {
|
|
37
|
+
const entries = Object.entries(value)
|
|
38
|
+
.filter(([, item]) => item !== undefined)
|
|
39
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
40
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
|
|
41
|
+
}
|
|
42
|
+
return JSON.stringify(value);
|
|
43
|
+
}
|
|
44
|
+
export function syncStateFor(kind, payload, placement) {
|
|
45
|
+
if (placement !== "device")
|
|
46
|
+
return "pending";
|
|
47
|
+
if (kind === "memory")
|
|
48
|
+
return "local";
|
|
49
|
+
if (kind === "obligation" && payload.state !== "discharged")
|
|
50
|
+
return "local";
|
|
51
|
+
return "pending";
|
|
52
|
+
}
|
|
53
|
+
export class AgentOwnedError extends Error {
|
|
54
|
+
ownerAgentDefinitionId;
|
|
55
|
+
naturalKey;
|
|
56
|
+
constructor(naturalKey, owner) {
|
|
57
|
+
super(`the obligation ${naturalKey} is owned by the agent definition ${owner}; only that agent discharges or changes it`);
|
|
58
|
+
this.name = "AgentOwnedError";
|
|
59
|
+
this.ownerAgentDefinitionId = owner;
|
|
60
|
+
this.naturalKey = naturalKey;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function findRecordByKey(db, idempotencyKey) {
|
|
64
|
+
const row = db.prepare("SELECT * FROM records WHERE idempotency_key = ?").get(idempotencyKey);
|
|
65
|
+
return row ? recordOf(row) : null;
|
|
66
|
+
}
|
|
67
|
+
function checkAgentOwned(stored, write, env) {
|
|
68
|
+
if (!stored || write.kind !== "obligation" || stored.kind !== "obligation")
|
|
69
|
+
return;
|
|
70
|
+
const storedPayload = JSON.parse(stored.payloadJson);
|
|
71
|
+
const owner = storedPayload.ownerAgentDefinitionId;
|
|
72
|
+
if (typeof owner !== "string" || owner === "")
|
|
73
|
+
return;
|
|
74
|
+
if (canonicalJson(storedPayload) === canonicalJson(write.payload))
|
|
75
|
+
return;
|
|
76
|
+
if (readVariable(AGENT_DEFINITION_ID_VARIABLE, env) === owner)
|
|
77
|
+
return;
|
|
78
|
+
throw new AgentOwnedError(write.naturalKey, owner);
|
|
79
|
+
}
|
|
80
|
+
function upsert(db, write, placement, syncState) {
|
|
81
|
+
db.prepare(`INSERT INTO records (id, kind, schema_version, project_id, session_id, plan_anchor, natural_key, idempotency_key,
|
|
82
|
+
payload_json, sync_state, attempts, next_attempt_at, last_error, created_at, repository_root, door_url,
|
|
83
|
+
placement, credential)
|
|
84
|
+
VALUES (:id, :kind, :schema_version, :project_id, :session_id, :plan_anchor, :natural_key, :idempotency_key,
|
|
85
|
+
:payload_json, :sync_state, 0, NULL, NULL, :created_at, :repository_root, NULL, :placement, :credential)
|
|
86
|
+
ON CONFLICT(idempotency_key) DO UPDATE SET
|
|
87
|
+
kind = excluded.kind, schema_version = excluded.schema_version, project_id = excluded.project_id,
|
|
88
|
+
session_id = excluded.session_id, plan_anchor = excluded.plan_anchor, natural_key = excluded.natural_key,
|
|
89
|
+
payload_json = excluded.payload_json, sync_state = excluded.sync_state, attempts = 0,
|
|
90
|
+
next_attempt_at = NULL, last_error = NULL, created_at = excluded.created_at,
|
|
91
|
+
repository_root = excluded.repository_root, door_url = excluded.door_url,
|
|
92
|
+
placement = records.placement, credential = excluded.credential`).run({
|
|
93
|
+
id: randomUUID(),
|
|
94
|
+
kind: write.kind,
|
|
95
|
+
schema_version: write.schemaVersion,
|
|
96
|
+
project_id: write.projectId,
|
|
97
|
+
session_id: write.sessionId,
|
|
98
|
+
plan_anchor: write.planAnchor,
|
|
99
|
+
natural_key: write.naturalKey,
|
|
100
|
+
idempotency_key: write.idempotencyKey,
|
|
101
|
+
payload_json: JSON.stringify(write.payload),
|
|
102
|
+
sync_state: syncState,
|
|
103
|
+
created_at: write.createdAt,
|
|
104
|
+
repository_root: write.repositoryRoot,
|
|
105
|
+
placement,
|
|
106
|
+
credential: write.credential,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
export function writeRecords(store, writes, env = process.env) {
|
|
110
|
+
return withTransaction(store, (db) => writes.map((write) => {
|
|
111
|
+
const stored = findRecordByKey(db, write.idempotencyKey);
|
|
112
|
+
checkAgentOwned(stored, write, env);
|
|
113
|
+
const placement = stored ? stored.placement : write.placement;
|
|
114
|
+
const syncState = syncStateFor(write.kind, write.payload, placement);
|
|
115
|
+
upsert(db, write, placement, syncState);
|
|
116
|
+
return { idempotencyKey: write.idempotencyKey, syncState };
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
export function releaseUnboundRows(store, root) {
|
|
120
|
+
store.db
|
|
121
|
+
.prepare(`UPDATE records SET next_attempt_at = NULL
|
|
122
|
+
WHERE project_id IS NULL AND repository_root = ? AND sync_state = 'pending' AND next_attempt_at IS NOT NULL`)
|
|
123
|
+
.run(root);
|
|
124
|
+
}
|
|
125
|
+
export function selectDueRecords(store, now, canSendDevice) {
|
|
126
|
+
const rows = store.db
|
|
127
|
+
.prepare(`SELECT * FROM records
|
|
128
|
+
WHERE sync_state = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
|
129
|
+
AND (credential IS NULL OR ?)
|
|
130
|
+
ORDER BY created_at ASC, rowid ASC`)
|
|
131
|
+
.all(now, canSendDevice ? 1 : 0);
|
|
132
|
+
return rows.map(recordOf);
|
|
133
|
+
}
|
|
134
|
+
export function hasDueRecord(db, now, canSendDevice) {
|
|
135
|
+
return (db
|
|
136
|
+
.prepare(`SELECT 1 FROM records
|
|
137
|
+
WHERE sync_state = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
|
138
|
+
AND (credential IS NULL OR ?) LIMIT 1`)
|
|
139
|
+
.get(now, canSendDevice ? 1 : 0) !== undefined);
|
|
140
|
+
}
|
|
141
|
+
const CAS = `id = :id AND sync_state = 'pending' AND payload_json = :payload_json AND session_id IS :session_id
|
|
142
|
+
AND plan_anchor IS :plan_anchor AND created_at = :created_at`;
|
|
143
|
+
function casParameters(row) {
|
|
144
|
+
return {
|
|
145
|
+
id: row.id,
|
|
146
|
+
payload_json: row.payloadJson,
|
|
147
|
+
session_id: row.sessionId,
|
|
148
|
+
plan_anchor: row.planAnchor,
|
|
149
|
+
created_at: row.createdAt,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
export function markSynced(store, row) {
|
|
153
|
+
const result = store.db
|
|
154
|
+
.prepare(`UPDATE records SET sync_state = 'synced', last_error = NULL, next_attempt_at = NULL WHERE ${CAS}`)
|
|
155
|
+
.run(casParameters(row));
|
|
156
|
+
return Number(result.changes) > 0;
|
|
157
|
+
}
|
|
158
|
+
export function markRefused(store, row, error) {
|
|
159
|
+
const result = store.db
|
|
160
|
+
.prepare(`UPDATE records SET sync_state = 'refused', last_error = :last_error, next_attempt_at = NULL WHERE ${CAS}`)
|
|
161
|
+
.run({ ...casParameters(row), last_error: error });
|
|
162
|
+
return Number(result.changes) > 0;
|
|
163
|
+
}
|
|
164
|
+
export function markBackoff(store, row, error, nextAttemptAt) {
|
|
165
|
+
const result = store.db
|
|
166
|
+
.prepare(`UPDATE records SET attempts = attempts + 1, next_attempt_at = :next_attempt_at, last_error = :last_error WHERE ${CAS}`)
|
|
167
|
+
.run({ ...casParameters(row), last_error: error, next_attempt_at: nextAttemptAt });
|
|
168
|
+
return Number(result.changes) > 0;
|
|
169
|
+
}
|
|
170
|
+
export function markDeferred(store, row, nextAttemptAt) {
|
|
171
|
+
const result = store.db
|
|
172
|
+
.prepare(`UPDATE records SET next_attempt_at = :next_attempt_at WHERE ${CAS}`)
|
|
173
|
+
.run({ ...casParameters(row), next_attempt_at: nextAttemptAt });
|
|
174
|
+
return Number(result.changes) > 0;
|
|
175
|
+
}
|
|
176
|
+
export function rekeyRecord(store, row, projectId) {
|
|
177
|
+
const key = `${projectId}:${row.kind}:${row.naturalKey}`;
|
|
178
|
+
return withTransaction(store, (db) => {
|
|
179
|
+
const current = db.prepare("SELECT * FROM records WHERE id = ?").get(row.id);
|
|
180
|
+
if (!current)
|
|
181
|
+
return null;
|
|
182
|
+
const existing = findRecordByKey(db, key);
|
|
183
|
+
if (existing && existing.id !== row.id) {
|
|
184
|
+
if (existing.createdAt >= row.createdAt) {
|
|
185
|
+
db.prepare("DELETE FROM records WHERE id = ?").run(row.id);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
db.prepare("DELETE FROM records WHERE id = ?").run(existing.id);
|
|
189
|
+
}
|
|
190
|
+
db.prepare("UPDATE records SET project_id = ?, idempotency_key = ? WHERE id = ?").run(projectId, key, row.id);
|
|
191
|
+
return recordOf(db.prepare("SELECT * FROM records WHERE id = ?").get(row.id));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
export function pruneSynced(store, now) {
|
|
195
|
+
store.db
|
|
196
|
+
.prepare(`DELETE FROM records WHERE sync_state = 'synced' AND created_at < ?
|
|
197
|
+
AND NOT (kind = 'obligation' AND json_extract(payload_json, '$.state') = 'open')`)
|
|
198
|
+
.run(now - SYNCED_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
|
199
|
+
}
|
|
200
|
+
function viewOf(row) {
|
|
201
|
+
let payload;
|
|
202
|
+
try {
|
|
203
|
+
payload = JSON.parse(row.payloadJson);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
payload = row.payloadJson;
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
id: row.id,
|
|
210
|
+
kind: row.kind,
|
|
211
|
+
projectId: row.projectId,
|
|
212
|
+
sessionId: row.sessionId,
|
|
213
|
+
planAnchor: row.planAnchor,
|
|
214
|
+
naturalKey: row.naturalKey,
|
|
215
|
+
idempotencyKey: row.idempotencyKey,
|
|
216
|
+
payload,
|
|
217
|
+
syncState: row.syncState,
|
|
218
|
+
attempts: row.attempts,
|
|
219
|
+
nextAttemptAt: row.nextAttemptAt,
|
|
220
|
+
lastError: row.lastError,
|
|
221
|
+
createdAt: row.createdAt,
|
|
222
|
+
repositoryRoot: row.repositoryRoot,
|
|
223
|
+
placement: row.placement,
|
|
224
|
+
credential: row.credential,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
export async function countRecords(store, projectId) {
|
|
228
|
+
const rows = (projectId
|
|
229
|
+
? store.db.prepare("SELECT * FROM records WHERE sync_state <> 'synced' AND project_id = ?").all(projectId)
|
|
230
|
+
: store.db.prepare("SELECT * FROM records WHERE sync_state <> 'synced'").all());
|
|
231
|
+
const records = rows.map(recordOf);
|
|
232
|
+
const doorOff = new Map();
|
|
233
|
+
for (const row of records) {
|
|
234
|
+
if (row.syncState !== "pending" || row.projectId === null || row.doorUrl !== null || row.repositoryRoot === null)
|
|
235
|
+
continue;
|
|
236
|
+
if (!doorOff.has(row.repositoryRoot))
|
|
237
|
+
doorOff.set(row.repositoryRoot, (await readMountDoor(row.repositoryRoot)) === null);
|
|
238
|
+
}
|
|
239
|
+
const isDoorOff = (row) => row.doorUrl === null && row.repositoryRoot !== null && doorOff.get(row.repositoryRoot) === true;
|
|
240
|
+
let waitingCount = 0;
|
|
241
|
+
let refusedWithProject = 0;
|
|
242
|
+
let storeCount = 0;
|
|
243
|
+
for (const row of records) {
|
|
244
|
+
if (row.syncState === "refused" && row.projectId !== null)
|
|
245
|
+
refusedWithProject += 1;
|
|
246
|
+
if (row.syncState !== "pending")
|
|
247
|
+
continue;
|
|
248
|
+
if (row.projectId === null || isDoorOff(row))
|
|
249
|
+
storeCount += 1;
|
|
250
|
+
else
|
|
251
|
+
waitingCount += 1;
|
|
252
|
+
}
|
|
253
|
+
const outboxCount = waitingCount + refusedWithProject;
|
|
254
|
+
const newest = records
|
|
255
|
+
.filter((row) => (row.syncState === "pending" || row.syncState === "refused") && row.lastError !== null)
|
|
256
|
+
.sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
257
|
+
return {
|
|
258
|
+
outboxCount,
|
|
259
|
+
storeCount,
|
|
260
|
+
lastError: newest?.lastError ?? null,
|
|
261
|
+
pendingCount: records.filter((row) => row.syncState === "pending").length,
|
|
262
|
+
refusedCount: records.filter((row) => row.syncState === "refused").length,
|
|
263
|
+
localCount: records.filter((row) => row.syncState === "local").length,
|
|
264
|
+
waitingCount,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
export function listRecords(store, filter = {}) {
|
|
268
|
+
const clauses = [];
|
|
269
|
+
const parameters = [];
|
|
270
|
+
if (filter.projectId) {
|
|
271
|
+
clauses.push("project_id = ?");
|
|
272
|
+
parameters.push(filter.projectId);
|
|
273
|
+
}
|
|
274
|
+
if (filter.kind) {
|
|
275
|
+
clauses.push("kind = ?");
|
|
276
|
+
parameters.push(filter.kind);
|
|
277
|
+
}
|
|
278
|
+
if (filter.planAnchor) {
|
|
279
|
+
clauses.push("plan_anchor = ?");
|
|
280
|
+
parameters.push(filter.planAnchor);
|
|
281
|
+
}
|
|
282
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
283
|
+
const rows = store.db
|
|
284
|
+
.prepare(`SELECT * FROM records ${where} ORDER BY created_at DESC, rowid DESC LIMIT ${RECORDS_LIST_LIMIT}`)
|
|
285
|
+
.all(...parameters);
|
|
286
|
+
return rows.map(recordOf).map(viewOf);
|
|
287
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { RECORD_SCHEMA_VERSION } from "./door.js";
|
|
3
|
+
import { idempotencyKeyOf } from "./naturalKey.js";
|
|
4
|
+
import { writeRecords } from "./recordsTable.js";
|
|
5
|
+
export const REFUSAL_GATE_IDS = ["records-validation", "records-unknown-kind", "agent-owned-obligation", "secret-scan"];
|
|
6
|
+
const TEXT_CAP = 20_000;
|
|
7
|
+
const clip = (text) => (text.length > TEXT_CAP ? `${text.slice(0, TEXT_CAP - 1)}…` : text || "-");
|
|
8
|
+
export function logRefusals(store, entries, context) {
|
|
9
|
+
if (entries.length === 0)
|
|
10
|
+
return 0;
|
|
11
|
+
try {
|
|
12
|
+
const writes = entries.map((entry) => {
|
|
13
|
+
const eventId = randomUUID();
|
|
14
|
+
const naturalKey = `event:${eventId}`;
|
|
15
|
+
return {
|
|
16
|
+
kind: "gate_event",
|
|
17
|
+
schemaVersion: RECORD_SCHEMA_VERSION,
|
|
18
|
+
projectId: context.projectId,
|
|
19
|
+
sessionId: context.sessionId,
|
|
20
|
+
planAnchor: null,
|
|
21
|
+
naturalKey,
|
|
22
|
+
idempotencyKey: idempotencyKeyOf(context.projectId ?? context.root, "gate_event", naturalKey),
|
|
23
|
+
payload: { eventId, gate: entry.gate, verdict: entry.verdict, target: clip(entry.target), reason: clip(entry.reason) },
|
|
24
|
+
createdAt: context.now.getTime(),
|
|
25
|
+
repositoryRoot: context.root,
|
|
26
|
+
placement: "server",
|
|
27
|
+
credential: context.credential,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
writeRecords(store, writes);
|
|
31
|
+
return writes.length;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function countRefusals(store, filter) {
|
|
38
|
+
const ids = REFUSAL_GATE_IDS.map(() => "?").join(", ");
|
|
39
|
+
const clauses = [
|
|
40
|
+
"kind = 'gate_event'",
|
|
41
|
+
"project_id IS ?",
|
|
42
|
+
`json_extract(payload_json, '$.gate') IN (${ids})`,
|
|
43
|
+
"json_extract(payload_json, '$.verdict') = ?",
|
|
44
|
+
];
|
|
45
|
+
const parameters = [filter.projectId, ...REFUSAL_GATE_IDS, filter.verdict ?? "block"];
|
|
46
|
+
if (filter.sessionId !== undefined) {
|
|
47
|
+
clauses.push("session_id = ?");
|
|
48
|
+
parameters.push(filter.sessionId);
|
|
49
|
+
}
|
|
50
|
+
if (filter.since !== undefined) {
|
|
51
|
+
clauses.push("created_at >= ?");
|
|
52
|
+
parameters.push(filter.since);
|
|
53
|
+
}
|
|
54
|
+
const row = store.db.prepare(`SELECT count(*) AS n FROM records WHERE ${clauses.join(" AND ")}`).get(...parameters);
|
|
55
|
+
return Number(row.n);
|
|
56
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const SECRET_PATTERNS = [
|
|
2
|
+
{ id: "private-key", pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----(?:[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----|[\s\S]*$)/g },
|
|
3
|
+
{ id: "nustack-token", pattern: /\bnst_[a-z]+_[A-Za-z0-9-]+_[0-9a-f]{16,}/g },
|
|
4
|
+
{ id: "nustack-service-token", pattern: /\basp_[A-Za-z0-9-]+_[A-Za-z0-9]{16,}/g },
|
|
5
|
+
{ id: "nustack-project-token", pattern: /\bwst_[A-Za-z0-9-]+_[0-9a-f]{64}/g },
|
|
6
|
+
{ id: "nustack-deploy-token", pattern: /\bnustack_deploy_[0-9a-f]{16,}/g },
|
|
7
|
+
{ id: "aws-access-key", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
|
|
8
|
+
{ id: "github-token", pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{20,})/g },
|
|
9
|
+
{ id: "anthropic-key", pattern: /\bsk-ant-[A-Za-z0-9_-]{16,}/g },
|
|
10
|
+
{ id: "openai-key", pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g },
|
|
11
|
+
{ id: "slack-token", pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g },
|
|
12
|
+
{ id: "google-api-key", pattern: /\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g },
|
|
13
|
+
{ id: "stripe-secret", pattern: /\b(?:sk|rk)_live_[0-9A-Za-z]{10,}/g },
|
|
14
|
+
{ id: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g },
|
|
15
|
+
];
|
|
16
|
+
function redactString(value, path, flags) {
|
|
17
|
+
let result = value;
|
|
18
|
+
for (const { id, pattern } of SECRET_PATTERNS) {
|
|
19
|
+
pattern.lastIndex = 0;
|
|
20
|
+
if (!pattern.test(result))
|
|
21
|
+
continue;
|
|
22
|
+
pattern.lastIndex = 0;
|
|
23
|
+
result = result.replace(pattern, `[redacted:${id}]`);
|
|
24
|
+
flags.push({ rule: "secret-scan", patternId: id, path });
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
function walk(value, path, flags) {
|
|
29
|
+
if (typeof value === "string")
|
|
30
|
+
return redactString(value, path, flags);
|
|
31
|
+
if (Array.isArray(value))
|
|
32
|
+
return value.map((item, index) => walk(item, `${path}[${index}]`, flags));
|
|
33
|
+
if (value && typeof value === "object") {
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const [key, item] of Object.entries(value))
|
|
36
|
+
out[key] = walk(item, `${path}.${key}`, flags);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
export function scanPayload(payload) {
|
|
42
|
+
const flags = [];
|
|
43
|
+
const redacted = walk(payload, "payload", flags);
|
|
44
|
+
return { payload: redacted, flags };
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/nustack",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.5",
|
|
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,
|
|
@@ -27,16 +27,17 @@
|
|
|
27
27
|
"commander": "^14.0.0"
|
|
28
28
|
},
|
|
29
29
|
"optionalDependencies": {
|
|
30
|
-
"@nurix/
|
|
31
|
-
"@nurix/
|
|
30
|
+
"@nurix/logscope": "0.1.0",
|
|
31
|
+
"@nurix/codegraph": "0.2.1"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@ngneat/falso": "^8.0.2",
|
|
35
35
|
"@types/node": "^22.19.20",
|
|
36
36
|
"typescript": "^5.7.2",
|
|
37
37
|
"vitest": "^4.1.11",
|
|
38
|
-
"@nurix/
|
|
39
|
-
"@nurix/nustack.ui": "0.0.0"
|
|
38
|
+
"@nurix/contracts": "2.0.1",
|
|
39
|
+
"@nurix/nustack.ui": "0.0.0",
|
|
40
|
+
"@nurix/nustack.studio": "0.0.0"
|
|
40
41
|
},
|
|
41
42
|
"publishConfig": {
|
|
42
43
|
"access": "public"
|