@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,183 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { openLocalStore } from "../lib/local_store/database.js";
|
|
3
|
+
import { idempotencyKeyOf, splitObligationKey } from "../lib/records/naturalKey.js";
|
|
4
|
+
import { findRecordByKey } from "../lib/records/recordsTable.js";
|
|
5
|
+
import { emitRows, refuseEmit, resolveCaller } from "./records.js";
|
|
6
|
+
export const RECORD_ALIAS_GROUPS = ["obligation", "backlog", "changelog", "journal"];
|
|
7
|
+
const VERDICTS_NEEDING_REASON = new Set(["owes_nothing", "blocked", "dismissed"]);
|
|
8
|
+
const PLAN_ANCHOR = /^\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*$/;
|
|
9
|
+
function requestOf(command, kind, rows, options) {
|
|
10
|
+
return {
|
|
11
|
+
command,
|
|
12
|
+
target: command,
|
|
13
|
+
kind,
|
|
14
|
+
rows,
|
|
15
|
+
json: options.json === true,
|
|
16
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
17
|
+
...(options.project !== undefined ? { project: options.project } : {}),
|
|
18
|
+
...(options.session !== undefined ? { session: options.session } : {}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function refusal(command, options, message, globals) {
|
|
22
|
+
return refuseEmit(requestOf(command, "obligation", [], options), "VALIDATION_FAILED", "records-validation", message, globals);
|
|
23
|
+
}
|
|
24
|
+
export async function runObligationAdd(options, globals) {
|
|
25
|
+
const command = "obligation add";
|
|
26
|
+
if (!options.plan && !options.origin)
|
|
27
|
+
return refusal(command, options, "obligation add takes --plan <anchor> or --origin <origin>", globals);
|
|
28
|
+
const payload = {
|
|
29
|
+
origin: options.origin ?? `plan:${options.plan}`,
|
|
30
|
+
type: options.type,
|
|
31
|
+
subject: options.subject,
|
|
32
|
+
...(options.claim !== undefined ? { claim: options.claim } : {}),
|
|
33
|
+
...(options.ownerAgent !== undefined ? { ownerAgentDefinitionId: options.ownerAgent } : {}),
|
|
34
|
+
state: "open",
|
|
35
|
+
};
|
|
36
|
+
await emitRows(requestOf(command, "obligation", [{ payload, ...(options.plan ? { planAnchor: options.plan } : {}) }], options), globals);
|
|
37
|
+
}
|
|
38
|
+
const COPIED_FIELDS = ["origin", "type", "subject", "claim", "ownerAgentDefinitionId", "isGateCounted"];
|
|
39
|
+
export async function runObligationDischarge(options, globals) {
|
|
40
|
+
const command = "obligation discharge";
|
|
41
|
+
const key = options.key ?? "";
|
|
42
|
+
if (options.outcome && VERDICTS_NEEDING_REASON.has(options.outcome) && !options.reason) {
|
|
43
|
+
return refusal(command, options, `--outcome ${options.outcome} needs a --reason`, globals);
|
|
44
|
+
}
|
|
45
|
+
const caller = await resolveCaller(options);
|
|
46
|
+
const store = await openLocalStore();
|
|
47
|
+
let stored;
|
|
48
|
+
try {
|
|
49
|
+
stored = findRecordByKey(store.db, idempotencyKeyOf(caller.projectId ?? caller.root, "obligation", key));
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
store.close();
|
|
53
|
+
}
|
|
54
|
+
let fields;
|
|
55
|
+
let planAnchor;
|
|
56
|
+
if (stored && stored.kind === "obligation") {
|
|
57
|
+
const storedPayload = JSON.parse(stored.payloadJson);
|
|
58
|
+
fields = {};
|
|
59
|
+
for (const field of COPIED_FIELDS)
|
|
60
|
+
if (storedPayload[field] !== undefined)
|
|
61
|
+
fields[field] = storedPayload[field];
|
|
62
|
+
planAnchor = stored.planAnchor;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const split = splitObligationKey(key);
|
|
66
|
+
if (!split)
|
|
67
|
+
return refusal(command, options, "--key must be an obligation's natural key, <origin>#<type>:<subject>", globals);
|
|
68
|
+
fields = { ...split };
|
|
69
|
+
const plan = split.origin.startsWith("plan:") ? split.origin.slice("plan:".length) : null;
|
|
70
|
+
planAnchor = plan !== null && PLAN_ANCHOR.test(plan) ? plan : null;
|
|
71
|
+
}
|
|
72
|
+
const payload = {
|
|
73
|
+
...fields,
|
|
74
|
+
state: "discharged",
|
|
75
|
+
outcome: { verdict: options.outcome, ...(options.reason !== undefined ? { reason: options.reason } : {}) },
|
|
76
|
+
};
|
|
77
|
+
await emitRows(requestOf(command, "obligation", [{ payload, ...(planAnchor !== null ? { planAnchor } : {}) }], options), globals);
|
|
78
|
+
}
|
|
79
|
+
export async function runBacklogAdd(options, globals) {
|
|
80
|
+
const payload = {
|
|
81
|
+
anchor: options.anchor,
|
|
82
|
+
priority: options.priority,
|
|
83
|
+
text: options.text,
|
|
84
|
+
...(options.discoveredBy !== undefined ? { discoveredBy: options.discoveredBy } : {}),
|
|
85
|
+
...(options.section !== undefined ? { section: options.section } : {}),
|
|
86
|
+
};
|
|
87
|
+
await emitRows(requestOf("backlog add", "backlog", [{ payload }], options), globals);
|
|
88
|
+
}
|
|
89
|
+
export async function runChangelogAdd(options, globals) {
|
|
90
|
+
const command = "changelog add";
|
|
91
|
+
if (!options.plan && !options.change)
|
|
92
|
+
return refusal(command, options, "changelog add takes --plan <anchor> or --change <change id>", globals);
|
|
93
|
+
let body = options.body;
|
|
94
|
+
if (body === undefined && options.bodyFile !== undefined) {
|
|
95
|
+
try {
|
|
96
|
+
body = await readFile(options.bodyFile, "utf8");
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
return refusal(command, options, `--body-file could not be read: ${error instanceof Error ? error.message : String(error)}`, globals);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (body === undefined)
|
|
103
|
+
return refusal(command, options, "changelog add takes --body <text> or --body-file <path>", globals);
|
|
104
|
+
const payload = {
|
|
105
|
+
origin: options.plan ? `plan:${options.plan}` : `change:${options.change}`,
|
|
106
|
+
title: options.title,
|
|
107
|
+
body,
|
|
108
|
+
...(options.commit && options.commit.length > 0 ? { commits: options.commit } : {}),
|
|
109
|
+
};
|
|
110
|
+
await emitRows(requestOf(command, "changelog", [{ payload, ...(options.plan ? { planAnchor: options.plan } : {}) }], options), globals);
|
|
111
|
+
}
|
|
112
|
+
export async function runJournalAdd(options, globals) {
|
|
113
|
+
const command = "journal add";
|
|
114
|
+
const caller = await resolveCaller(options);
|
|
115
|
+
const sessionId = caller.refusalContext.sessionId;
|
|
116
|
+
if (!options.plan && !sessionId) {
|
|
117
|
+
return refusal(command, options, "journal add takes --plan <anchor>, or a session (--session or $NUSTACK_SESSION_ID)", globals);
|
|
118
|
+
}
|
|
119
|
+
const occurredAt = caller.refusalContext.now.toISOString();
|
|
120
|
+
const payload = {
|
|
121
|
+
origin: options.plan ? `plan:${options.plan}` : `session:${sessionId}`,
|
|
122
|
+
type: "documentation",
|
|
123
|
+
subject: `journal-${occurredAt}`,
|
|
124
|
+
claim: options.text,
|
|
125
|
+
isGateCounted: false,
|
|
126
|
+
state: "open",
|
|
127
|
+
};
|
|
128
|
+
await emitRows(requestOf(command, "obligation", [{ payload, occurredAt, ...(options.plan ? { planAnchor: options.plan } : {}) }], options), globals);
|
|
129
|
+
}
|
|
130
|
+
const collect = (value, previous = []) => [...previous, value];
|
|
131
|
+
function withCommon(command) {
|
|
132
|
+
return command
|
|
133
|
+
.option("--root <dir>", "the folder the record is about (default: the current directory)")
|
|
134
|
+
.option("--project <id>", "the Project, when the folder carries no binding marker")
|
|
135
|
+
.option("--session <id>", "the session the record belongs to (default: $NUSTACK_SESSION_ID)")
|
|
136
|
+
.option("--json", "emit the versioned machine envelope");
|
|
137
|
+
}
|
|
138
|
+
export function registerRecordAliases(program, getGlobalOptions) {
|
|
139
|
+
const obligation = program.command("obligation").description("Open and discharge obligations (harness records)");
|
|
140
|
+
withCommon(obligation
|
|
141
|
+
.command("add")
|
|
142
|
+
.description("Open an obligation under a plan or an origin")
|
|
143
|
+
.option("--plan <anchor>", "the plan the obligation belongs to (origin plan:<anchor>)")
|
|
144
|
+
.option("--origin <origin>", "the origin, when it is not a plan: plan:, change:, path:, session: or run:")
|
|
145
|
+
.requiredOption("--type <type>", "documentation, test, security, or an agent-owned type")
|
|
146
|
+
.requiredOption("--subject <text>", "what the obligation is about: a doc path, a row number, a finding target")
|
|
147
|
+
.option("--claim <text>", "what is owed")
|
|
148
|
+
.option("--owner-agent <slug>", "the agent definition slug that alone discharges it")).action(async (options) => runObligationAdd(options, getGlobalOptions()));
|
|
149
|
+
withCommon(obligation
|
|
150
|
+
.command("discharge")
|
|
151
|
+
.description("Discharge an obligation by its natural key, with a verdict")
|
|
152
|
+
.requiredOption("--key <naturalKey>", "the obligation's natural key, <origin>#<type>:<subject>")
|
|
153
|
+
.requiredOption("--outcome <verdict>", "updated, satisfied, fixed, owes_nothing, blocked or dismissed")
|
|
154
|
+
.option("--reason <text>", "why; required for owes_nothing, blocked and dismissed")).action(async (options) => runObligationDischarge(options, getGlobalOptions()));
|
|
155
|
+
withCommon(program
|
|
156
|
+
.command("backlog")
|
|
157
|
+
.description("Log backlog items (harness records)")
|
|
158
|
+
.command("add")
|
|
159
|
+
.description("Log one backlog item")
|
|
160
|
+
.requiredOption("--anchor <anchor>", "the item's stable id")
|
|
161
|
+
.requiredOption("--text <text>", "the item")
|
|
162
|
+
.requiredOption("--priority <priority>", "P0, P1, P2 or P3")
|
|
163
|
+
.option("--discovered-by <who>", "the plan or agent that found it")
|
|
164
|
+
.option("--section <name>", "the backlog section")).action(async (options) => runBacklogAdd(options, getGlobalOptions()));
|
|
165
|
+
withCommon(program
|
|
166
|
+
.command("changelog")
|
|
167
|
+
.description("Write changelog entries (harness records)")
|
|
168
|
+
.command("add")
|
|
169
|
+
.description("Write a changelog entry for a plan or a change; a later call supersedes it")
|
|
170
|
+
.option("--plan <anchor>", "the plan the entry describes")
|
|
171
|
+
.option("--change <id>", "the NuStack Change the entry describes")
|
|
172
|
+
.requiredOption("--title <line>", "the entry's one-line summary")
|
|
173
|
+
.option("--body <text>", "the entry's body")
|
|
174
|
+
.option("--body-file <path>", "read the entry's body from a file")
|
|
175
|
+
.option("--commit <sha>", "a commit the entry covers (repeatable)", collect)).action(async (options) => runChangelogAdd(options, getGlobalOptions()));
|
|
176
|
+
withCommon(program
|
|
177
|
+
.command("journal")
|
|
178
|
+
.description("Write journal entries (harness records)")
|
|
179
|
+
.command("add")
|
|
180
|
+
.description("Write a journal entry: a documentation obligation the gate never counts")
|
|
181
|
+
.requiredOption("--text <text>", "the entry")
|
|
182
|
+
.option("--plan <anchor>", "the plan the entry belongs to (else the session)")).action(async (options) => runJournalAdd(options, getGlobalOptions()));
|
|
183
|
+
}
|
package/dist/commands/records.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { readDesktopSettings } from "../lib/desktop_settings.js";
|
|
2
3
|
import { openLocalStore } from "../lib/local_store/database.js";
|
|
3
4
|
import { stateDbPath } from "../lib/local_store/paths.js";
|
|
4
5
|
import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
|
|
5
|
-
import {
|
|
6
|
+
import { resolveProjectId } from "../lib/project_context.js";
|
|
7
|
+
import { isEmitKind } from "../lib/records/door.js";
|
|
8
|
+
import { acquireDrainLock, drainRecords, refreshDrainLock, releaseDrainLock, startDetachedDrain } from "../lib/records/drain.js";
|
|
9
|
+
import { DEVICE_TOKEN_VARIABLE, DRAIN_CHILD_VARIABLE, SESSION_ID_VARIABLE, isFlagSet, readVariable, recordCredentialOf, } from "../lib/records/environment.js";
|
|
10
|
+
import { UNKNOWN_KIND_MESSAGE, assembleRecord, parseRowsText, translateRow, } from "../lib/records/recordInput.js";
|
|
11
|
+
import { AgentOwnedError, OBLIGATION_AGENT_OWNED, countRecords, hasDueRecord, listRecords, releaseUnboundRows, writeRecords, } from "../lib/records/recordsTable.js";
|
|
12
|
+
import { logRefusals } from "../lib/records/refusalLog.js";
|
|
6
13
|
async function readStdin() {
|
|
7
14
|
if (process.stdin.isTTY)
|
|
8
15
|
return "";
|
|
@@ -11,101 +18,180 @@ async function readStdin() {
|
|
|
11
18
|
chunks.push(chunk);
|
|
12
19
|
return Buffer.concat(chunks).toString("utf8");
|
|
13
20
|
}
|
|
14
|
-
function
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
export async function resolveCaller(request) {
|
|
22
|
+
const root = request.root ?? process.cwd();
|
|
23
|
+
const project = await resolveProjectId(root, request.project ? { project: request.project } : {});
|
|
24
|
+
const projectId = project.ok ? project.projectId : null;
|
|
25
|
+
return {
|
|
26
|
+
root,
|
|
27
|
+
projectId,
|
|
28
|
+
refusalContext: {
|
|
29
|
+
projectId,
|
|
30
|
+
root,
|
|
31
|
+
sessionId: request.session ?? readVariable(SESSION_ID_VARIABLE),
|
|
32
|
+
credential: recordCredentialOf(),
|
|
33
|
+
now: new Date(),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export async function refuseEmit(request, code, gate, message, globals, caller) {
|
|
38
|
+
const who = caller ?? (await resolveCaller(request).catch(() => null));
|
|
39
|
+
if (who) {
|
|
40
|
+
try {
|
|
41
|
+
const store = await openLocalStore();
|
|
42
|
+
try {
|
|
43
|
+
if (logRefusals(store, [{ gate, verdict: "block", target: request.target, reason: message }], who.refusalContext) > 0) {
|
|
44
|
+
startDetachedDrain({ nustackUrl: globals.nustackUrl });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
store.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
}
|
|
21
53
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
list = parsed.rows;
|
|
28
|
-
kind = parsed.kind;
|
|
54
|
+
if (request.json)
|
|
55
|
+
printJsonError(request.command, { code, message });
|
|
56
|
+
else {
|
|
57
|
+
process.stderr.write(`${request.command}: ${message}\n`);
|
|
58
|
+
process.exitCode = 1;
|
|
29
59
|
}
|
|
30
|
-
else
|
|
31
|
-
list = [parsed];
|
|
32
|
-
const rows = list.filter((row) => !!row &&
|
|
33
|
-
typeof row === "object" &&
|
|
34
|
-
!Array.isArray(row) &&
|
|
35
|
-
typeof row.anchor === "string" &&
|
|
36
|
-
row.anchor.trim() !== "");
|
|
37
|
-
return rows.length === list.length ? { rows, kind } : null;
|
|
38
|
-
}
|
|
39
|
-
function isEnvelope(value) {
|
|
40
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
41
|
-
return false;
|
|
42
|
-
const candidate = value;
|
|
43
|
-
const hasPayload = !!candidate.payload && typeof candidate.payload === "object" && !Array.isArray(candidate.payload);
|
|
44
|
-
return Array.isArray(candidate.rows) && typeof candidate.anchor !== "string" && !hasPayload;
|
|
45
60
|
}
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
return
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
printJsonError("records emit", { code: "VALIDATION_FAILED", message });
|
|
58
|
-
else {
|
|
59
|
-
process.stderr.write(`records emit: ${message}\n`);
|
|
60
|
-
process.exitCode = 1;
|
|
61
|
+
export async function emitRows(request, globals) {
|
|
62
|
+
const caller = await resolveCaller(request);
|
|
63
|
+
const refuse = (code, gate, message) => refuseEmit(request, code, gate, message, globals, caller);
|
|
64
|
+
if (!isEmitKind(request.kind))
|
|
65
|
+
return refuse("VALIDATION_FAILED", "records-unknown-kind", UNKNOWN_KIND_MESSAGE);
|
|
66
|
+
const kind = request.kind;
|
|
67
|
+
let rows = request.rows;
|
|
68
|
+
if (!rows) {
|
|
69
|
+
const parsed = parseRowsText(request.rowText ?? (await readStdin()));
|
|
70
|
+
if (!parsed || parsed.rows.length === 0) {
|
|
71
|
+
return refuse("VALIDATION_FAILED", "records-validation", "the rows must be JSON — one row, an array of rows, or {kind, rows}");
|
|
61
72
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (!parsed || parsed.rows.length === 0) {
|
|
67
|
-
return refuse("the rows must be JSON — one row, an array of rows, or {kind, rows} — each an object with an anchor");
|
|
73
|
+
if (parsed.kind !== undefined && parsed.kind !== kind) {
|
|
74
|
+
return refuse("VALIDATION_FAILED", "records-validation", `the envelope's kind ${JSON.stringify(parsed.kind)} differs from --kind ${kind}`);
|
|
75
|
+
}
|
|
76
|
+
rows = parsed.rows;
|
|
68
77
|
}
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
const context = {
|
|
79
|
+
projectId: caller.projectId,
|
|
80
|
+
root: caller.root,
|
|
81
|
+
sessionId: caller.refusalContext.sessionId,
|
|
82
|
+
planAnchor: request.plan ?? null,
|
|
83
|
+
settings: readDesktopSettings(),
|
|
84
|
+
credential: caller.refusalContext.credential,
|
|
85
|
+
now: caller.refusalContext.now,
|
|
86
|
+
};
|
|
87
|
+
const writes = [];
|
|
88
|
+
const flags = [];
|
|
89
|
+
for (const [index, row] of rows.entries()) {
|
|
90
|
+
const translated = translateRow(kind, row, index);
|
|
91
|
+
if (!translated.ok)
|
|
92
|
+
return refuse("VALIDATION_FAILED", "records-validation", translated.message);
|
|
93
|
+
const assembled = assembleRecord(translated.kind, translated.input, context, index);
|
|
94
|
+
if (!assembled.ok)
|
|
95
|
+
return refuse("VALIDATION_FAILED", "records-validation", assembled.message);
|
|
96
|
+
writes.push(assembled.write);
|
|
97
|
+
flags.push(...assembled.flags);
|
|
71
98
|
}
|
|
72
|
-
const rows = parsed.rows;
|
|
73
|
-
const root = options.root ?? process.cwd();
|
|
74
|
-
const resolution = await resolveDoor(root, globals, options.project);
|
|
75
99
|
const store = await openLocalStore();
|
|
100
|
+
let written;
|
|
101
|
+
let owned = null;
|
|
76
102
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
103
|
+
try {
|
|
104
|
+
written = writeRecords(store, writes);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (!(error instanceof AgentOwnedError))
|
|
108
|
+
throw error;
|
|
109
|
+
owned = error;
|
|
110
|
+
written = [];
|
|
80
111
|
}
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
if (owned)
|
|
113
|
+
return;
|
|
114
|
+
if (caller.projectId !== null)
|
|
115
|
+
releaseUnboundRows(store, caller.root);
|
|
116
|
+
logRefusals(store, flags.map((flag) => ({
|
|
117
|
+
gate: "secret-scan",
|
|
118
|
+
verdict: "flag",
|
|
119
|
+
target: request.target,
|
|
120
|
+
reason: `redacted a ${flag.patternId} match at ${flag.path}`,
|
|
121
|
+
})), caller.refusalContext);
|
|
89
122
|
}
|
|
90
123
|
finally {
|
|
91
124
|
store.close();
|
|
125
|
+
if (owned)
|
|
126
|
+
await refuse(OBLIGATION_AGENT_OWNED, "agent-owned-obligation", owned.message);
|
|
127
|
+
}
|
|
128
|
+
if (owned)
|
|
129
|
+
return;
|
|
130
|
+
if (flags.length > 0 || written.some((row) => row.syncState === "pending"))
|
|
131
|
+
startDetachedDrain({ nustackUrl: globals.nustackUrl });
|
|
132
|
+
const data = {
|
|
133
|
+
written: written.length,
|
|
134
|
+
keys: written.map((row) => row.idempotencyKey),
|
|
135
|
+
local: written.filter((row) => row.syncState === "local").length,
|
|
136
|
+
flags,
|
|
137
|
+
projectId: caller.projectId,
|
|
138
|
+
};
|
|
139
|
+
if (request.json)
|
|
140
|
+
printJsonSuccess(request.command, data);
|
|
141
|
+
else {
|
|
142
|
+
for (const flag of flags)
|
|
143
|
+
process.stderr.write(`${request.command}: redacted a ${flag.patternId} match at ${flag.path}\n`);
|
|
144
|
+
process.stdout.write(`${request.command}: wrote ${data.written} record(s) locally; sending in the background\n`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export async function runRecordsEmit(options, globals) {
|
|
148
|
+
await emitRows({
|
|
149
|
+
command: "records emit",
|
|
150
|
+
target: `records emit --kind ${options.kind ?? ""}`.trim(),
|
|
151
|
+
kind: options.kind,
|
|
152
|
+
...(options.row !== undefined ? { rowText: options.row } : {}),
|
|
153
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
154
|
+
...(options.project !== undefined ? { project: options.project } : {}),
|
|
155
|
+
...(options.session !== undefined ? { session: options.session } : {}),
|
|
156
|
+
...(options.plan !== undefined ? { plan: options.plan } : {}),
|
|
157
|
+
json: options.json === true,
|
|
158
|
+
}, globals);
|
|
159
|
+
}
|
|
160
|
+
async function drainAs(store, globals, isChild) {
|
|
161
|
+
if (!isChild)
|
|
162
|
+
return drainRecords(store, globals, { isForeground: true });
|
|
163
|
+
if (!acquireDrainLock())
|
|
164
|
+
return null;
|
|
165
|
+
try {
|
|
166
|
+
return await drainRecords(store, globals, { onPass: () => refreshDrainLock() });
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
releaseDrainLock();
|
|
92
170
|
}
|
|
93
171
|
}
|
|
94
172
|
export async function runRecordsSync(options, globals) {
|
|
95
173
|
const store = await openLocalStore();
|
|
96
174
|
try {
|
|
97
|
-
const
|
|
98
|
-
const
|
|
175
|
+
const root = process.cwd();
|
|
176
|
+
const project = await resolveProjectId(root, {});
|
|
177
|
+
if (project.ok)
|
|
178
|
+
releaseUnboundRows(store, root);
|
|
179
|
+
const drained = await drainAs(store, globals, isFlagSet(DRAIN_CHILD_VARIABLE));
|
|
180
|
+
if (options.quiet)
|
|
181
|
+
return;
|
|
182
|
+
const counts = await countRecords(store);
|
|
99
183
|
const result = {
|
|
100
|
-
sent:
|
|
101
|
-
waiting:
|
|
102
|
-
stored:
|
|
103
|
-
|
|
184
|
+
sent: drained?.sent ?? 0,
|
|
185
|
+
waiting: counts.waitingCount,
|
|
186
|
+
stored: counts.storeCount,
|
|
187
|
+
refused: counts.refusedCount,
|
|
188
|
+
lastError: drained?.lastError ?? counts.lastError,
|
|
104
189
|
};
|
|
105
190
|
if (options.json)
|
|
106
191
|
printJsonSuccess("records sync", result);
|
|
107
|
-
else
|
|
108
|
-
process.stdout.write(`records sync: sent ${result.sent}; ${result.waiting}
|
|
192
|
+
else {
|
|
193
|
+
process.stdout.write(`records sync: sent ${result.sent}; ${result.waiting} waiting, ${result.stored} kept locally, ${result.refused} refused\n`);
|
|
194
|
+
}
|
|
109
195
|
}
|
|
110
196
|
finally {
|
|
111
197
|
store.close();
|
|
@@ -114,35 +200,42 @@ export async function runRecordsSync(options, globals) {
|
|
|
114
200
|
export async function runRecordsList(options) {
|
|
115
201
|
const store = await openLocalStore();
|
|
116
202
|
try {
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
203
|
+
const counts = await countRecords(store, options.project);
|
|
204
|
+
const records = listRecords(store, {
|
|
205
|
+
...(options.project ? { projectId: options.project } : {}),
|
|
206
|
+
...(options.kind ? { kind: options.kind } : {}),
|
|
207
|
+
...(options.plan ? { planAnchor: options.plan } : {}),
|
|
208
|
+
});
|
|
120
209
|
const data = {
|
|
121
|
-
outboxCount:
|
|
122
|
-
storeCount:
|
|
123
|
-
lastError:
|
|
124
|
-
|
|
125
|
-
|
|
210
|
+
outboxCount: counts.outboxCount,
|
|
211
|
+
storeCount: counts.storeCount,
|
|
212
|
+
lastError: counts.lastError,
|
|
213
|
+
pendingCount: counts.pendingCount,
|
|
214
|
+
refusedCount: counts.refusedCount,
|
|
215
|
+
localCount: counts.localCount,
|
|
216
|
+
records,
|
|
126
217
|
};
|
|
127
218
|
if (options.json)
|
|
128
219
|
printJsonSuccess("records list", data);
|
|
129
220
|
else {
|
|
130
|
-
process.stdout.write(`records list: ${
|
|
131
|
-
if (
|
|
132
|
-
process.stdout.write(` last error: ${
|
|
221
|
+
process.stdout.write(`records list: ${counts.pendingCount} pending, ${counts.refusedCount} refused, ${counts.localCount} kept on this device\n`);
|
|
222
|
+
if (counts.lastError)
|
|
223
|
+
process.stdout.write(` last error: ${counts.lastError}\n`);
|
|
133
224
|
}
|
|
134
225
|
}
|
|
135
226
|
finally {
|
|
136
227
|
store.close();
|
|
137
228
|
}
|
|
138
229
|
}
|
|
139
|
-
export async function
|
|
140
|
-
if (!existsSync(stateDbPath()))
|
|
141
|
-
return;
|
|
230
|
+
export async function startDrainIfDue(globals) {
|
|
142
231
|
try {
|
|
232
|
+
if (isFlagSet(DRAIN_CHILD_VARIABLE) || !existsSync(stateDbPath()))
|
|
233
|
+
return;
|
|
143
234
|
const store = await openLocalStore();
|
|
144
235
|
try {
|
|
145
|
-
|
|
236
|
+
if (hasDueRecord(store.db, Date.now(), readVariable(DEVICE_TOKEN_VARIABLE) !== null)) {
|
|
237
|
+
startDetachedDrain({ nustackUrl: globals.nustackUrl });
|
|
238
|
+
}
|
|
146
239
|
}
|
|
147
240
|
finally {
|
|
148
241
|
store.close();
|
|
@@ -154,22 +247,27 @@ export async function drainBeforeCommand(globals) {
|
|
|
154
247
|
export function registerRecordsCommands(recordsCommand, getGlobalOptions) {
|
|
155
248
|
recordsCommand
|
|
156
249
|
.command("emit")
|
|
157
|
-
.description("
|
|
158
|
-
.option("--kind <kind>", "
|
|
250
|
+
.description("Write records locally and return; a background drain sends them to NuStack")
|
|
251
|
+
.option("--kind <kind>", "obligation, gate_event, changelog, memory, backlog, proposal or triage")
|
|
159
252
|
.option("--row <json>", "one row, an array of rows, or {kind, rows}, as JSON (default: read from stdin)")
|
|
160
253
|
.option("--root <dir>", "the folder the records are about (default: the current directory)")
|
|
161
254
|
.option("--project <id>", "the Project, when the folder carries no binding marker")
|
|
255
|
+
.option("--session <id>", `the session the records belong to (default: $${SESSION_ID_VARIABLE})`)
|
|
256
|
+
.option("--plan <anchor>", "the plan anchor, when a row names none")
|
|
162
257
|
.option("--json", "emit the versioned machine envelope")
|
|
163
258
|
.action(async (options) => runRecordsEmit(options, getGlobalOptions()));
|
|
164
259
|
recordsCommand
|
|
165
260
|
.command("sync")
|
|
166
|
-
.description("Send every
|
|
261
|
+
.description("Send every due record this device holds to NuStack")
|
|
262
|
+
.option("--quiet", "print nothing")
|
|
167
263
|
.option("--json", "emit the versioned machine envelope")
|
|
168
264
|
.action(async (options) => runRecordsSync(options, getGlobalOptions()));
|
|
169
265
|
recordsCommand
|
|
170
266
|
.command("list")
|
|
171
|
-
.description("Show
|
|
267
|
+
.description("Show this device's records: what waits, what was refused, what stays local")
|
|
172
268
|
.option("--project <id>", "only this Project's rows")
|
|
269
|
+
.option("--kind <kind>", "only rows of this kind")
|
|
270
|
+
.option("--plan <anchor>", "only rows of this plan")
|
|
173
271
|
.option("--json", "emit the versioned machine envelope")
|
|
174
272
|
.action(async (options) => runRecordsList(options));
|
|
175
273
|
}
|
package/dist/index.js
CHANGED
|
@@ -33,7 +33,8 @@ import { runToolkitStatus, runToolkitReconcile } from "./commands/toolkit.js";
|
|
|
33
33
|
import { runHarnessMount, runHarnessStatus } from "./commands/harness.js";
|
|
34
34
|
import { runSessionsList, runSessionsPrune, runSessionsRecord, runSessionsServe, } from "./commands/sessions.js";
|
|
35
35
|
import { registerReviewCommands } from "./commands/review.js";
|
|
36
|
-
import {
|
|
36
|
+
import { registerRecordsCommands, startDrainIfDue } from "./commands/records.js";
|
|
37
|
+
import { RECORD_ALIAS_GROUPS, registerRecordAliases } from "./commands/recordAliases.js";
|
|
37
38
|
import { runAgentContext, runAgentHookInstall, runAgentHookRemove } from "./commands/agent.js";
|
|
38
39
|
import { printJsonError } from "./lib/json_output.js";
|
|
39
40
|
import { setCliVersion } from "./lib/auth_grant.js";
|
|
@@ -237,11 +238,13 @@ registerActivityCommand(project, () => program.opts());
|
|
|
237
238
|
registerProjectSyncCommands(project, () => program.opts());
|
|
238
239
|
registerReviewCommands(program.command("review").description("Read and mirror NuStack Review items"), () => program.opts());
|
|
239
240
|
registerRecordsCommands(program.command("records").description("Emit, send and list harness records"), () => program.opts());
|
|
241
|
+
registerRecordAliases(program, () => program.opts());
|
|
240
242
|
program.hook("preAction", async (_program, action) => {
|
|
241
243
|
if (program.opts().nonInteractive)
|
|
242
244
|
process.env[NON_INTERACTIVE_VARIABLE] = "1";
|
|
243
|
-
|
|
244
|
-
|
|
245
|
+
const group = action.parent?.name() ?? "";
|
|
246
|
+
if (group !== "records" && !RECORD_ALIAS_GROUPS.includes(group))
|
|
247
|
+
await startDrainIfDue(program.opts());
|
|
245
248
|
});
|
|
246
249
|
const docs = program.command("docs").description("Work with a Project's Documents");
|
|
247
250
|
docs
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { desktopSettingsPath } from "./local_store/paths.js";
|
|
3
|
+
const DEFAULT_PLACEMENT = "server";
|
|
4
|
+
function placementOf(value) {
|
|
5
|
+
return value === "device" || value === "server" ? value : DEFAULT_PLACEMENT;
|
|
6
|
+
}
|
|
7
|
+
export function readDesktopSettings() {
|
|
8
|
+
const defaults = { placement: { memory: DEFAULT_PLACEMENT, securityFindings: DEFAULT_PLACEMENT } };
|
|
9
|
+
let parsed;
|
|
10
|
+
try {
|
|
11
|
+
parsed = JSON.parse(readFileSync(desktopSettingsPath(), "utf8"));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return defaults;
|
|
15
|
+
}
|
|
16
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
17
|
+
return defaults;
|
|
18
|
+
const file = parsed;
|
|
19
|
+
if (file.schemaVersion !== 1)
|
|
20
|
+
return defaults;
|
|
21
|
+
const placement = file.placement && typeof file.placement === "object" ? file.placement : {};
|
|
22
|
+
return { placement: { memory: placementOf(placement.memory), securityFindings: placementOf(placement.securityFindings) } };
|
|
23
|
+
}
|
|
@@ -41,8 +41,8 @@ export async function openLocalStore(options = {}) {
|
|
|
41
41
|
const { DatabaseSync } = await loadSqlite();
|
|
42
42
|
return new DatabaseSync(dbFile);
|
|
43
43
|
});
|
|
44
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
45
44
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
45
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
46
46
|
db.exec("PRAGMA foreign_keys = ON");
|
|
47
47
|
migrateToLatest(db);
|
|
48
48
|
await chmod(dbFile, 0o600).catch(() => { });
|