@nurix/nustack 0.10.0-dev.9 → 0.11.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/README.md +1 -0
- package/dist/commands/bootstrap.js +4 -4
- package/dist/commands/docs.js +13 -13
- package/dist/commands/doctor.js +51 -17
- package/dist/commands/handoff.js +8 -8
- package/dist/commands/init.js +43 -43
- package/dist/commands/login.js +19 -6
- package/dist/commands/logout.js +14 -6
- package/dist/commands/logscope.js +55 -0
- package/dist/commands/{workspace-sync.js → project-sync.js} +5 -5
- package/dist/commands/{workspace.js → project.js} +133 -133
- package/dist/commands/review.js +38 -15
- package/dist/commands/search.js +7 -7
- package/dist/commands/sessions.js +156 -5
- package/dist/commands/sync.js +10 -10
- package/dist/commands/token.js +102 -0
- package/dist/commands/toolkit.js +19 -19
- package/dist/index.js +88 -36
- package/dist/lib/auth_grant.js +32 -0
- package/dist/lib/auth_recovery.js +3 -1
- package/dist/lib/credential_store.js +106 -0
- package/dist/lib/device_identity.js +6 -6
- package/dist/lib/device_keychain.js +16 -37
- package/dist/lib/etna/profile.js +9 -9
- package/dist/lib/etna/reconcile.js +11 -11
- package/dist/lib/git-sync/core/engine.js +4 -4
- package/dist/lib/git-sync/inbound/apply_commit.js +3 -3
- package/dist/lib/git-sync/inbound/approval.js +1 -1
- package/dist/lib/git-sync/inbound/download.js +8 -8
- package/dist/lib/git-sync/inbound/event_sync.js +13 -13
- package/dist/lib/git-sync/inbound/notification.js +1 -1
- package/dist/lib/git-sync/outbound/extraction.js +4 -4
- package/dist/lib/git-sync/outbound/history_decision.js +2 -2
- package/dist/lib/git-sync/outbound/publisher.js +2 -2
- package/dist/lib/git-sync/outbound/queue.js +3 -3
- package/dist/lib/git-sync/outbound/upload_client.js +4 -4
- package/dist/lib/git-sync/whitelist/index.js +1 -1
- package/dist/lib/git-sync/whitelist/types.js +1 -1
- package/dist/lib/git-sync/whitelist/validation.js +5 -5
- package/dist/lib/libsecret_store.js +47 -0
- package/dist/lib/local_store/accepted_uploads.js +5 -5
- package/dist/lib/local_store/bindings.js +11 -11
- package/dist/lib/local_store/index.js +1 -1
- package/dist/lib/local_store/migrations.js +13 -0
- package/dist/lib/local_store/project_events_cache.js +54 -0
- package/dist/lib/logs/answer.js +99 -0
- package/dist/lib/logs/library.js +19 -0
- package/dist/lib/logs/lifecycle.js +159 -0
- package/dist/lib/nustack_client.js +88 -46
- package/dist/lib/onboarding/attach_existing_git.js +7 -7
- package/dist/lib/onboarding/binding.js +11 -11
- package/dist/lib/onboarding/folder_state.js +10 -10
- package/dist/lib/onboarding/handoff_apply.js +2 -2
- package/dist/lib/onboarding/init_fresh_folder.js +18 -18
- package/dist/lib/onboarding/manifest_writer.js +31 -31
- package/dist/lib/onboarding/web_first_attach.js +12 -12
- package/dist/lib/{workspace_context.js → project_context.js} +24 -24
- package/dist/lib/review/client.js +1 -1
- package/dist/lib/{lanes → session-engine}/claudeAdapter.js +3 -3
- package/dist/lib/{lanes → session-engine}/frames.js +36 -27
- package/dist/lib/session-engine/prune.js +233 -0
- package/dist/lib/session-engine/registry.js +83 -0
- package/dist/lib/session-engine/supervisor.js +321 -0
- package/dist/lib/session-engine/worktree.js +89 -0
- package/dist/lib/session_telemetry.js +2 -2
- package/dist/lib/sessions/client.js +8 -8
- package/dist/lib/sessions/outsideSessions.js +6 -6
- package/dist/lib/telemetry/config_renderer.js +3 -3
- package/dist/lib/telemetry/health_report.js +1 -1
- package/package.json +3 -2
- package/dist/commands/lane.js +0 -12
- package/dist/lib/lanes/supervisor.js +0 -247
- package/dist/lib/lanes/worktree.js +0 -33
- package/dist/lib/local_store/workspace_events_cache.js +0 -54
|
@@ -11,12 +11,12 @@ import { readTargetState } from "./content_decision.js";
|
|
|
11
11
|
import { computeStateFingerprint } from "./state_fingerprint.js";
|
|
12
12
|
import { persistRecoveryBundle } from "../recovery/store.js";
|
|
13
13
|
export const NUSTACK_SYNC_IDENTITY = { name: "NuStack Sync", email: "sync@nustack.local" };
|
|
14
|
-
export function buildCommitMessage(handoffId,
|
|
14
|
+
export function buildCommitMessage(handoffId, projectId, approvedByUserId) {
|
|
15
15
|
const lines = [
|
|
16
16
|
`nustack(sync): apply cloud handoff ${handoffId.slice(0, 12)}`,
|
|
17
17
|
"",
|
|
18
18
|
`NuStack-Handoff: ${handoffId}`,
|
|
19
|
-
`NuStack-
|
|
19
|
+
`NuStack-Project: ${projectId}`,
|
|
20
20
|
"NuStack-Origin: cloud",
|
|
21
21
|
];
|
|
22
22
|
if (approvedByUserId)
|
|
@@ -186,7 +186,7 @@ export async function applyCommit(input) {
|
|
|
186
186
|
if (target.operation === "upsert" && target.blobPath)
|
|
187
187
|
bytesByPath.set(target.path, readFileSync(target.blobPath));
|
|
188
188
|
}
|
|
189
|
-
const message = buildCommitMessage(handoffId, manifest.
|
|
189
|
+
const message = buildCommitMessage(handoffId, manifest.projectId, approved ? (input.approvedByUserId ?? null) : null);
|
|
190
190
|
const built = buildCommit(repo, oldHead, targets, bytesByPath, message, {
|
|
191
191
|
authorName: NUSTACK_SYNC_IDENTITY.name,
|
|
192
192
|
authorEmail: NUSTACK_SYNC_IDENTITY.email,
|
|
@@ -12,7 +12,7 @@ export const APPROVAL_RECOVERY_NOTE = "Approving overwrites the listed paths. Gi
|
|
|
12
12
|
export function toDownloadBinding(binding) {
|
|
13
13
|
return {
|
|
14
14
|
id: binding.id,
|
|
15
|
-
|
|
15
|
+
projectId: binding.projectId,
|
|
16
16
|
boundBranch: binding.boundBranch,
|
|
17
17
|
repositoryRoot: binding.repositoryRoot,
|
|
18
18
|
etnaProfileJson: binding.etnaProfileJson,
|
|
@@ -27,7 +27,7 @@ function recomputeManifestHash(manifest) {
|
|
|
27
27
|
const core = {
|
|
28
28
|
schemaVersion: 1,
|
|
29
29
|
handoffId: manifest.handoffId,
|
|
30
|
-
|
|
30
|
+
projectId: manifest.projectId,
|
|
31
31
|
documentSetVersionId: manifest.documentSetVersionId,
|
|
32
32
|
toolkit: {
|
|
33
33
|
name: manifest.toolkit.name,
|
|
@@ -62,7 +62,7 @@ function toolkitTechnicalWait(binding, manifest) {
|
|
|
62
62
|
return "harness_incompatible";
|
|
63
63
|
return null;
|
|
64
64
|
}
|
|
65
|
-
function
|
|
65
|
+
function crossProjectMarker(body, projectId) {
|
|
66
66
|
if (!body.startsWith("---\n"))
|
|
67
67
|
return false;
|
|
68
68
|
const secondLineEnd = body.indexOf("\n", 4);
|
|
@@ -71,8 +71,8 @@ function crossWorkspaceMarker(body, workspaceId) {
|
|
|
71
71
|
return false;
|
|
72
72
|
const fence = body.indexOf("\n---", 4);
|
|
73
73
|
const block = body.slice(0, fence === -1 ? body.length : fence);
|
|
74
|
-
const match = /\n\s*
|
|
75
|
-
return match !== null && match[1] !==
|
|
74
|
+
const match = /\n\s*project_id:\s*(\S+)/.exec(block);
|
|
75
|
+
return match !== null && match[1] !== projectId;
|
|
76
76
|
}
|
|
77
77
|
function isUnsupportedMedia(bytes) {
|
|
78
78
|
for (const byte of bytes)
|
|
@@ -136,8 +136,8 @@ export async function downloadHandoff(deps, binding, handoffId) {
|
|
|
136
136
|
const downloadContent = deps.downloadContent ?? downloadTargetContent;
|
|
137
137
|
const fetchWhitelist = deps.fetchWhitelist ?? fetchSyncWhitelist;
|
|
138
138
|
const manifest = await fetchManifest(deps.baseUrl, deps.accessToken, { handoffId, bindingId: binding.id });
|
|
139
|
-
if (manifest.
|
|
140
|
-
return reject("
|
|
139
|
+
if (manifest.projectId !== binding.projectId)
|
|
140
|
+
return reject("project_mismatch", "manifest Project does not match the binding");
|
|
141
141
|
if (recomputeManifestHash(manifest) !== manifest.manifestSha256) {
|
|
142
142
|
return reject("manifest_hash", "manifest hash does not verify");
|
|
143
143
|
}
|
|
@@ -207,8 +207,8 @@ export async function downloadHandoff(deps, binding, handoffId) {
|
|
|
207
207
|
const actualSha256 = sha256(bytes);
|
|
208
208
|
if (actualSha256 !== target.targetSha256)
|
|
209
209
|
return reject("hash_mismatch", "downloaded body hash does not match the manifest");
|
|
210
|
-
if (
|
|
211
|
-
return reject("
|
|
210
|
+
if (crossProjectMarker(new TextDecoder().decode(bytes), binding.projectId)) {
|
|
211
|
+
return reject("cross_project_marker", "target body declares a foreign Project id");
|
|
212
212
|
}
|
|
213
213
|
const blobPath = blobCachePath(actualSha256);
|
|
214
214
|
await mkdirSecure(path.dirname(blobPath));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getBindingById, updateBindingCursor } from "../../local_store/bindings.js";
|
|
2
2
|
import { hasCloudOriginForHandoff } from "../../local_store/commit_origins.js";
|
|
3
|
-
import {
|
|
3
|
+
import { cacheProjectEvents, listCachedProjectEvents, pruneProjectEvents, } from "../../local_store/project_events_cache.js";
|
|
4
4
|
import { getApplication, upsertApplication, transitionApplicationState, listApplicationsForBinding, } from "../../local_store/handoff_applications.js";
|
|
5
5
|
import { fetchBindingEvents, acknowledgeBindingCursor } from "../../nustack_client.js";
|
|
6
6
|
const PENDING_STATES = new Set([
|
|
@@ -29,7 +29,7 @@ function supersedeOlderApplications(store, bindingId, newerHandoffId) {
|
|
|
29
29
|
return flipped;
|
|
30
30
|
}
|
|
31
31
|
function handoffIdOf(event) {
|
|
32
|
-
return event.type === "version.created" && event.entity.kind === "
|
|
32
|
+
return event.type === "version.created" && event.entity.kind === "project_document_version" ? event.entity.id : null;
|
|
33
33
|
}
|
|
34
34
|
export async function syncBindingEvents(store, deps, bindingId) {
|
|
35
35
|
const binding = getBindingById(store, bindingId);
|
|
@@ -40,9 +40,9 @@ export async function syncBindingEvents(store, deps, bindingId) {
|
|
|
40
40
|
createdHandoffs: [],
|
|
41
41
|
supersededHandoffs: [],
|
|
42
42
|
acknowledgedOwnOrigin: [],
|
|
43
|
-
highestSequence: binding.
|
|
43
|
+
highestSequence: binding.lastAppliedProjectSequence,
|
|
44
44
|
};
|
|
45
|
-
let after = binding.
|
|
45
|
+
let after = binding.lastAppliedProjectSequence;
|
|
46
46
|
for (;;) {
|
|
47
47
|
const events = await fetchEvents(deps.baseUrl, deps.accessToken, {
|
|
48
48
|
bindingId,
|
|
@@ -51,8 +51,8 @@ export async function syncBindingEvents(store, deps, bindingId) {
|
|
|
51
51
|
});
|
|
52
52
|
if (events.length === 0)
|
|
53
53
|
break;
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
cacheProjectEvents(store, bindingId, events.map((event) => ({
|
|
55
|
+
projectSequence: event.sequence,
|
|
56
56
|
eventType: event.type,
|
|
57
57
|
payloadJson: JSON.stringify({ entity: event.entity, payload: event.payload }),
|
|
58
58
|
})));
|
|
@@ -81,7 +81,7 @@ export async function syncBindingEvents(store, deps, bindingId) {
|
|
|
81
81
|
if (events.length < EVENTS_PAGE_LIMIT)
|
|
82
82
|
break;
|
|
83
83
|
}
|
|
84
|
-
|
|
84
|
+
pruneProjectEvents(store, bindingId);
|
|
85
85
|
return result;
|
|
86
86
|
}
|
|
87
87
|
function cachedHandoffId(eventType, payloadJson) {
|
|
@@ -100,9 +100,9 @@ export async function advanceContiguousCursor(store, deps, bindingId) {
|
|
|
100
100
|
if (!binding)
|
|
101
101
|
throw new Error(`No binding "${bindingId}" on this machine.`);
|
|
102
102
|
const acknowledgeCursor = deps.acknowledgeCursor ?? acknowledgeBindingCursor;
|
|
103
|
-
let cursor = binding.
|
|
104
|
-
for (const event of
|
|
105
|
-
if (event.
|
|
103
|
+
let cursor = binding.lastAppliedProjectSequence;
|
|
104
|
+
for (const event of listCachedProjectEvents(store, bindingId)) {
|
|
105
|
+
if (event.projectSequence <= cursor)
|
|
106
106
|
continue;
|
|
107
107
|
const handoffId = cachedHandoffId(event.eventType, event.payloadJson);
|
|
108
108
|
if (handoffId !== null) {
|
|
@@ -110,10 +110,10 @@ export async function advanceContiguousCursor(store, deps, bindingId) {
|
|
|
110
110
|
if (!application || PENDING_STATES.has(application.state))
|
|
111
111
|
break;
|
|
112
112
|
}
|
|
113
|
-
cursor = event.
|
|
113
|
+
cursor = event.projectSequence;
|
|
114
114
|
}
|
|
115
|
-
if (cursor > binding.
|
|
116
|
-
updateBindingCursor(store, bindingId, {
|
|
115
|
+
if (cursor > binding.lastAppliedProjectSequence) {
|
|
116
|
+
updateBindingCursor(store, bindingId, { lastAppliedProjectSequence: cursor });
|
|
117
117
|
try {
|
|
118
118
|
await acknowledgeCursor(deps.baseUrl, deps.accessToken, { bindingId, lastAppliedSequence: cursor });
|
|
119
119
|
}
|
|
@@ -7,7 +7,7 @@ export function renderCommitNotification(application, changedPaths) {
|
|
|
7
7
|
lines.push(` commit: ${application.generatedCommitId.slice(0, 12)}`);
|
|
8
8
|
if (changedPaths.length > 0)
|
|
9
9
|
lines.push(` changed: ${changedPaths.join(", ")}`);
|
|
10
|
-
lines.push(` View: nustack
|
|
10
|
+
lines.push(` View: nustack project sync pending Undo: nustack project sync undo ${application.handoffId}`);
|
|
11
11
|
if (application.recoveryBundleId) {
|
|
12
12
|
lines.push(` Recovery bundle ${application.recoveryBundleId} holds your pre-approval content until it expires.`);
|
|
13
13
|
}
|
|
@@ -43,11 +43,11 @@ function readBlob(repo, blobId) {
|
|
|
43
43
|
export function isCommitTreeReadable(repo, commitId) {
|
|
44
44
|
return runGit(repo.repositoryRoot, ["cat-file", "-e", `${commitId}^{tree}`], { allowFailure: true }).exitCode === 0;
|
|
45
45
|
}
|
|
46
|
-
export function extractCommitDelta(repo, commitId, whitelist,
|
|
46
|
+
export function extractCommitDelta(repo, commitId, whitelist, projectId, direction = "local_to_cloud") {
|
|
47
47
|
const parentCommitId = readRefCommit(repo, `${commitId}^1`);
|
|
48
|
-
return computeTreeDelta(repo, parentCommitId, commitId, whitelist,
|
|
48
|
+
return computeTreeDelta(repo, parentCommitId, commitId, whitelist, projectId, direction);
|
|
49
49
|
}
|
|
50
|
-
export function computeTreeDelta(repo, baseCommitId, targetCommitId, whitelist,
|
|
50
|
+
export function computeTreeDelta(repo, baseCommitId, targetCommitId, whitelist, projectId, direction = "local_to_cloud") {
|
|
51
51
|
const diffArgs = ["diff-tree", "-r", "-z", "--no-renames", "--no-commit-id"];
|
|
52
52
|
const args = baseCommitId === null ? [...diffArgs, "--root", targetCommitId] : [...diffArgs, baseCommitId, targetCommitId];
|
|
53
53
|
const { stdout } = runGit(repo.repositoryRoot, args);
|
|
@@ -81,7 +81,7 @@ export function computeTreeDelta(repo, baseCommitId, targetCommitId, whitelist,
|
|
|
81
81
|
bytes: blob.bytes,
|
|
82
82
|
byteSize: blob.byteSize,
|
|
83
83
|
entry: match.entry,
|
|
84
|
-
|
|
84
|
+
projectId,
|
|
85
85
|
});
|
|
86
86
|
if (!validation.ok)
|
|
87
87
|
return { ok: false, code: validation.code, path };
|
|
@@ -49,11 +49,11 @@ export async function publishCurrentState(store, binding, repo, deps) {
|
|
|
49
49
|
repo,
|
|
50
50
|
client: deps.client,
|
|
51
51
|
whitelistRef: { current: whitelist },
|
|
52
|
-
|
|
52
|
+
projectId: binding.projectId,
|
|
53
53
|
now: deps.now ?? Date.now,
|
|
54
54
|
};
|
|
55
55
|
for (let staleDepth = 0;; staleDepth++) {
|
|
56
|
-
const delta = computeTreeDelta(repo, base, head, ctx.whitelistRef.current, binding.
|
|
56
|
+
const delta = computeTreeDelta(repo, base, head, ctx.whitelistRef.current, binding.projectId);
|
|
57
57
|
if (!delta.ok)
|
|
58
58
|
return { ok: false, code: delta.code, detail: delta.path };
|
|
59
59
|
const result = await pushOperations(ctx, head, base, delta.delta.operations);
|
|
@@ -19,7 +19,7 @@ export async function runOutboundPublisher(ctx, runtime) {
|
|
|
19
19
|
repo,
|
|
20
20
|
client: runtime.client,
|
|
21
21
|
whitelistRef: { current: whitelist },
|
|
22
|
-
|
|
22
|
+
projectId: binding.projectId,
|
|
23
23
|
now,
|
|
24
24
|
};
|
|
25
25
|
for (const commitId of commitIds) {
|
|
@@ -32,7 +32,7 @@ export async function runOutboundPublisher(ctx, runtime) {
|
|
|
32
32
|
switch (outcome.kind) {
|
|
33
33
|
case "accepted":
|
|
34
34
|
updateBindingCursor(store, binding.id, { lastProcessedCommitId: commitId });
|
|
35
|
-
emit({ event: "upload_result", bindingId: binding.id, commitId, outcome: "accepted", handoffId: outcome.handoffId,
|
|
35
|
+
emit({ event: "upload_result", bindingId: binding.id, commitId, outcome: "accepted", handoffId: outcome.handoffId, projectSequence: outcome.projectSequence });
|
|
36
36
|
break;
|
|
37
37
|
case "no_op":
|
|
38
38
|
updateBindingCursor(store, binding.id, { lastProcessedCommitId: commitId });
|
|
@@ -24,7 +24,7 @@ export async function submitHead(ctx, commitId) {
|
|
|
24
24
|
return attempt(ctx, commitId, 0);
|
|
25
25
|
}
|
|
26
26
|
async function attempt(ctx, commitId, staleDepth) {
|
|
27
|
-
const extraction = extractCommitDelta(ctx.repo, commitId, ctx.whitelistRef.current, ctx.
|
|
27
|
+
const extraction = extractCommitDelta(ctx.repo, commitId, ctx.whitelistRef.current, ctx.projectId);
|
|
28
28
|
if (!extraction.ok) {
|
|
29
29
|
return blockBinding(ctx, commitId, extraction.code, { path: extraction.path });
|
|
30
30
|
}
|
|
@@ -85,11 +85,11 @@ export async function pushOperations(ctx, commitId, parentCommitId, operations)
|
|
|
85
85
|
bindingId: ctx.binding.id,
|
|
86
86
|
commitId,
|
|
87
87
|
handoffId: outcome.accepted.handoffId,
|
|
88
|
-
|
|
88
|
+
projectSequence: outcome.accepted.projectSequence,
|
|
89
89
|
manifestSha256: built.manifestSha256,
|
|
90
90
|
});
|
|
91
91
|
deleteUpload(ctx.store, ctx.binding.id, commitId);
|
|
92
|
-
return { kind: "accepted", handoffId: outcome.accepted.handoffId,
|
|
92
|
+
return { kind: "accepted", handoffId: outcome.accepted.handoffId, projectSequence: outcome.accepted.projectSequence };
|
|
93
93
|
}
|
|
94
94
|
async function repinWhitelist(ctx) {
|
|
95
95
|
try {
|
|
@@ -18,7 +18,7 @@ const TERMINAL_CODES = new Set([
|
|
|
18
18
|
"INVALID_STATE",
|
|
19
19
|
"IDEMPOTENCY_CONFLICT",
|
|
20
20
|
"HANDOFF_SUPERSEDED",
|
|
21
|
-
"
|
|
21
|
+
"PROJECT_NOT_FOUND",
|
|
22
22
|
"DOCUMENT_NOT_FOUND",
|
|
23
23
|
]);
|
|
24
24
|
const AUTH_CODES = new Set(["AUTH_REQUIRED", "ACCESS_TOKEN_EXPIRED", "DEVICE_CREDENTIAL_INVALID"]);
|
|
@@ -120,11 +120,11 @@ export function createUploadClient(deps) {
|
|
|
120
120
|
const data = (response.body?.data ?? {});
|
|
121
121
|
if (data.noOp)
|
|
122
122
|
return { ok: true, value: { noOp: true } };
|
|
123
|
-
const
|
|
124
|
-
if (!data.handoffId || typeof
|
|
123
|
+
const projectSequence = data.projectSequence ?? response.body?.meta?.projectSequence;
|
|
124
|
+
if (!data.handoffId || typeof projectSequence !== "number") {
|
|
125
125
|
return { ok: false, error: { kind: "terminal", code: "MALFORMED_ACCEPTANCE" } };
|
|
126
126
|
}
|
|
127
|
-
return { ok: true, value: { accepted: { handoffId: data.handoffId,
|
|
127
|
+
return { ok: true, value: { accepted: { handoffId: data.handoffId, projectSequence } } };
|
|
128
128
|
});
|
|
129
129
|
},
|
|
130
130
|
reportHealth(bindingId, report) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { SYNC_PROTOCOL_VERSION, isWhitelistManifest, } from "./types.js";
|
|
2
2
|
export { matchPath, isProtectedPath, globMatch } from "./matcher.js";
|
|
3
|
-
export { validateOperationContent, mediaTypeForPath,
|
|
3
|
+
export { validateOperationContent, mediaTypeForPath, frontmatterProjectId, } from "./validation.js";
|
|
4
4
|
export { fetchWhitelist, pinWhitelist, loadPinnedWhitelist, canonicalWhitelistSha256, whitelistCacheFile, WhitelistError, } from "./store.js";
|
|
@@ -36,7 +36,7 @@ export function isWhitelistManifest(value) {
|
|
|
36
36
|
return (manifest.schemaVersion === 1 &&
|
|
37
37
|
typeof manifest.manifestId === "string" &&
|
|
38
38
|
typeof manifest.version === "number" &&
|
|
39
|
-
typeof manifest.
|
|
39
|
+
typeof manifest.projectId === "string" &&
|
|
40
40
|
typeof manifest.protocolVersion === "string" &&
|
|
41
41
|
Array.isArray(manifest.entries) &&
|
|
42
42
|
manifest.entries.every(isEntry) &&
|
|
@@ -14,7 +14,7 @@ export function mediaTypeForPath(path) {
|
|
|
14
14
|
const ext = path.slice(dot + 1).toLowerCase();
|
|
15
15
|
return MEDIA_TYPE_BY_EXTENSION[ext] ?? null;
|
|
16
16
|
}
|
|
17
|
-
export function
|
|
17
|
+
export function frontmatterProjectId(text) {
|
|
18
18
|
if (!text.startsWith("---"))
|
|
19
19
|
return null;
|
|
20
20
|
const firstNewline = text.indexOf("\n");
|
|
@@ -26,7 +26,7 @@ export function frontmatterWorkspaceId(text) {
|
|
|
26
26
|
const closeIndex = rest.search(/\n---\s*(\n|$)/);
|
|
27
27
|
const block = closeIndex < 0 ? rest : rest.slice(0, closeIndex);
|
|
28
28
|
for (const line of block.split("\n")) {
|
|
29
|
-
const match = /^\s*(?:
|
|
29
|
+
const match = /^\s*(?:project_id|projectId|app_id|appId)\s*:\s*(.+?)\s*$/.exec(line);
|
|
30
30
|
if (match)
|
|
31
31
|
return match[1].replace(/^["']|["']$/g, "");
|
|
32
32
|
}
|
|
@@ -52,9 +52,9 @@ export function validateOperationContent(input) {
|
|
|
52
52
|
}
|
|
53
53
|
if (text.includes(NUL))
|
|
54
54
|
return { ok: false, code: "invalid_encoding" };
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
return { ok: false, code: "
|
|
55
|
+
const declaredProject = frontmatterProjectId(text);
|
|
56
|
+
if (declaredProject !== null && declaredProject !== input.projectId) {
|
|
57
|
+
return { ok: false, code: "cross_project_marker" };
|
|
58
58
|
}
|
|
59
59
|
return { ok: true };
|
|
60
60
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
const runFile = promisify(execFile);
|
|
5
|
+
const DEFAULT_SECRET_TOOL_BIN = "/usr/bin/secret-tool";
|
|
6
|
+
const SERVICE = "nustack-device";
|
|
7
|
+
const LABEL = "NuStack device credential";
|
|
8
|
+
function secretToolBin() {
|
|
9
|
+
return process.env.NUSTACK_SECRET_TOOL_BIN?.trim() || DEFAULT_SECRET_TOOL_BIN;
|
|
10
|
+
}
|
|
11
|
+
export function isLibsecretAvailable() {
|
|
12
|
+
if (process.env.NUSTACK_SECRET_TOOL_BIN)
|
|
13
|
+
return true;
|
|
14
|
+
return process.platform === "linux" && existsSync(DEFAULT_SECRET_TOOL_BIN);
|
|
15
|
+
}
|
|
16
|
+
function attributes(account) {
|
|
17
|
+
return ["service", SERVICE, "account", account];
|
|
18
|
+
}
|
|
19
|
+
export const libsecretStore = {
|
|
20
|
+
name: "libsecret",
|
|
21
|
+
async readRaw(account) {
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await runFile(secretToolBin(), ["lookup", ...attributes(account)]);
|
|
24
|
+
return stdout.trim() || null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
writeRaw(account, payload) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const child = execFile(secretToolBin(), ["store", `--label=${LABEL}`, ...attributes(account)], (error) => error ? reject(error) : resolve());
|
|
33
|
+
child.stdin?.end(payload);
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
async deleteRaw(account) {
|
|
37
|
+
if (!(await this.readRaw(account)))
|
|
38
|
+
return false;
|
|
39
|
+
try {
|
|
40
|
+
await runFile(secretToolBin(), ["clear", ...attributes(account)]);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -3,7 +3,7 @@ function mapRow(row) {
|
|
|
3
3
|
bindingId: row.binding_id,
|
|
4
4
|
commitId: row.commit_id,
|
|
5
5
|
handoffId: row.handoff_id,
|
|
6
|
-
|
|
6
|
+
projectSequence: row.project_sequence,
|
|
7
7
|
manifestSha256: row.manifest_sha256,
|
|
8
8
|
createdAtMs: row.created_at_ms,
|
|
9
9
|
};
|
|
@@ -11,16 +11,16 @@ function mapRow(row) {
|
|
|
11
11
|
export function recordAcceptedUpload(store, input) {
|
|
12
12
|
store.db
|
|
13
13
|
.prepare(`INSERT INTO local_accepted_uploads (
|
|
14
|
-
binding_id, commit_id, handoff_id,
|
|
14
|
+
binding_id, commit_id, handoff_id, project_sequence, manifest_sha256, created_at_ms
|
|
15
15
|
) VALUES (
|
|
16
|
-
:binding_id, :commit_id, :handoff_id, :
|
|
16
|
+
:binding_id, :commit_id, :handoff_id, :project_sequence, :manifest_sha256, :created_at_ms
|
|
17
17
|
)
|
|
18
18
|
ON CONFLICT(binding_id, commit_id) DO NOTHING`)
|
|
19
19
|
.run({
|
|
20
20
|
binding_id: input.bindingId,
|
|
21
21
|
commit_id: input.commitId,
|
|
22
22
|
handoff_id: input.handoffId,
|
|
23
|
-
|
|
23
|
+
project_sequence: input.projectSequence,
|
|
24
24
|
manifest_sha256: input.manifestSha256,
|
|
25
25
|
created_at_ms: Date.now(),
|
|
26
26
|
});
|
|
@@ -42,7 +42,7 @@ export function latestAcceptedUpload(store, bindingId) {
|
|
|
42
42
|
const row = store.db
|
|
43
43
|
.prepare(`SELECT * FROM local_accepted_uploads
|
|
44
44
|
WHERE binding_id = :binding_id
|
|
45
|
-
ORDER BY
|
|
45
|
+
ORDER BY project_sequence DESC, created_at_ms DESC
|
|
46
46
|
LIMIT 1`)
|
|
47
47
|
.get({ binding_id: bindingId });
|
|
48
48
|
return row ? mapRow(row) : null;
|
|
@@ -3,7 +3,7 @@ import { LIVE_BINDING_STATUSES } from "./sync_vocabulary.js";
|
|
|
3
3
|
function mapRow(row) {
|
|
4
4
|
return {
|
|
5
5
|
id: row.id,
|
|
6
|
-
|
|
6
|
+
projectId: row.project_id,
|
|
7
7
|
organizationId: row.organization_id,
|
|
8
8
|
repositoryRoot: row.repository_root,
|
|
9
9
|
gitDir: row.git_dir,
|
|
@@ -11,7 +11,7 @@ function mapRow(row) {
|
|
|
11
11
|
repositoryFingerprint: row.repository_fingerprint,
|
|
12
12
|
boundBranch: row.bound_branch,
|
|
13
13
|
lastProcessedCommitId: row.last_processed_commit_id ?? null,
|
|
14
|
-
|
|
14
|
+
lastAppliedProjectSequence: row.last_applied_project_sequence,
|
|
15
15
|
whitelistManifestId: row.whitelist_manifest_id ?? null,
|
|
16
16
|
whitelistManifestSha256: row.whitelist_manifest_sha256 ?? null,
|
|
17
17
|
whitelistCachePath: row.whitelist_cache_path ?? null,
|
|
@@ -28,21 +28,21 @@ export function insertBinding(store, input) {
|
|
|
28
28
|
const now = Date.now();
|
|
29
29
|
store.db
|
|
30
30
|
.prepare(`INSERT INTO local_bindings (
|
|
31
|
-
id,
|
|
31
|
+
id, project_id, organization_id, repository_root, git_dir, git_common_dir,
|
|
32
32
|
repository_fingerprint, bound_branch, last_processed_commit_id,
|
|
33
|
-
|
|
33
|
+
last_applied_project_sequence, whitelist_manifest_id, whitelist_manifest_sha256,
|
|
34
34
|
whitelist_cache_path, etna_profile_json, status, last_error_code,
|
|
35
35
|
last_error_detail, created_at_ms, updated_at_ms
|
|
36
36
|
) VALUES (
|
|
37
|
-
:id, :
|
|
37
|
+
:id, :project_id, :organization_id, :repository_root, :git_dir, :git_common_dir,
|
|
38
38
|
:repository_fingerprint, :bound_branch, :last_processed_commit_id,
|
|
39
|
-
:
|
|
39
|
+
:last_applied_project_sequence, :whitelist_manifest_id, :whitelist_manifest_sha256,
|
|
40
40
|
:whitelist_cache_path, :etna_profile_json, :status, NULL,
|
|
41
41
|
NULL, :created_at_ms, :updated_at_ms
|
|
42
42
|
)`)
|
|
43
43
|
.run({
|
|
44
44
|
id: input.id,
|
|
45
|
-
|
|
45
|
+
project_id: input.projectId,
|
|
46
46
|
organization_id: input.organizationId,
|
|
47
47
|
repository_root: input.repositoryRoot,
|
|
48
48
|
git_dir: input.gitDir,
|
|
@@ -50,7 +50,7 @@ export function insertBinding(store, input) {
|
|
|
50
50
|
repository_fingerprint: input.repositoryFingerprint,
|
|
51
51
|
bound_branch: input.boundBranch,
|
|
52
52
|
last_processed_commit_id: input.lastProcessedCommitId ?? null,
|
|
53
|
-
|
|
53
|
+
last_applied_project_sequence: input.lastAppliedProjectSequence ?? 0,
|
|
54
54
|
whitelist_manifest_id: input.whitelistManifestId ?? null,
|
|
55
55
|
whitelist_manifest_sha256: input.whitelistManifestSha256 ?? null,
|
|
56
56
|
whitelist_cache_path: input.whitelistCachePath ?? null,
|
|
@@ -103,12 +103,12 @@ export function updateBindingCursor(store, bindingId, patch) {
|
|
|
103
103
|
WHERE id = :id`)
|
|
104
104
|
.run({ commit: patch.lastProcessedCommitId ?? null, now, id: bindingId });
|
|
105
105
|
}
|
|
106
|
-
if (patch.
|
|
106
|
+
if (patch.lastAppliedProjectSequence !== undefined) {
|
|
107
107
|
store.db
|
|
108
108
|
.prepare(`UPDATE local_bindings
|
|
109
|
-
SET
|
|
109
|
+
SET last_applied_project_sequence = :seq, updated_at_ms = :now
|
|
110
110
|
WHERE id = :id`)
|
|
111
|
-
.run({ seq: patch.
|
|
111
|
+
.run({ seq: patch.lastAppliedProjectSequence, now, id: bindingId });
|
|
112
112
|
}
|
|
113
113
|
return getBindingById(store, bindingId);
|
|
114
114
|
});
|
|
@@ -10,5 +10,5 @@ export * from "./recovery_bundles.js";
|
|
|
10
10
|
export * from "./pending_uploads.js";
|
|
11
11
|
export * from "./history_decisions.js";
|
|
12
12
|
export * from "./accepted_uploads.js";
|
|
13
|
-
export * from "./
|
|
13
|
+
export * from "./project_events_cache.js";
|
|
14
14
|
export * from "./filebeat_state.js";
|
|
@@ -193,6 +193,18 @@ const MIGRATION_V6 = {
|
|
|
193
193
|
`CREATE INDEX local_bindings_workspace_idx ON local_bindings (workspace_id)`,
|
|
194
194
|
],
|
|
195
195
|
};
|
|
196
|
+
const MIGRATION_V7 = {
|
|
197
|
+
version: 7,
|
|
198
|
+
statements: [
|
|
199
|
+
`ALTER TABLE local_bindings RENAME COLUMN workspace_id TO project_id`,
|
|
200
|
+
`ALTER TABLE local_bindings RENAME COLUMN last_applied_workspace_sequence TO last_applied_project_sequence`,
|
|
201
|
+
`ALTER TABLE cached_workspace_events RENAME COLUMN workspace_sequence TO project_sequence`,
|
|
202
|
+
`ALTER TABLE local_accepted_uploads RENAME COLUMN workspace_sequence TO project_sequence`,
|
|
203
|
+
`ALTER TABLE cached_workspace_events RENAME TO cached_project_events`,
|
|
204
|
+
`DROP INDEX IF EXISTS local_bindings_workspace_idx`,
|
|
205
|
+
`CREATE INDEX local_bindings_project_idx ON local_bindings (project_id)`,
|
|
206
|
+
],
|
|
207
|
+
};
|
|
196
208
|
export const MIGRATIONS = [
|
|
197
209
|
MIGRATION_V1,
|
|
198
210
|
MIGRATION_V2,
|
|
@@ -200,6 +212,7 @@ export const MIGRATIONS = [
|
|
|
200
212
|
MIGRATION_V4,
|
|
201
213
|
MIGRATION_V5,
|
|
202
214
|
MIGRATION_V6,
|
|
215
|
+
MIGRATION_V7,
|
|
203
216
|
];
|
|
204
217
|
export const LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
|
205
218
|
export class LocalSchemaTooNewError extends Error {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const DEFAULT_PROJECT_EVENT_KEEP_COUNT = 500;
|
|
2
|
+
function mapRow(row) {
|
|
3
|
+
return {
|
|
4
|
+
bindingId: row.binding_id,
|
|
5
|
+
projectSequence: row.project_sequence,
|
|
6
|
+
eventType: row.event_type,
|
|
7
|
+
payloadJson: row.payload_json ?? null,
|
|
8
|
+
appliedAtMs: row.applied_at_ms ?? null,
|
|
9
|
+
createdAtMs: row.created_at_ms,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function cacheProjectEvents(store, bindingId, events) {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
const insert = store.db.prepare(`INSERT INTO cached_project_events (
|
|
15
|
+
binding_id, project_sequence, event_type, payload_json, applied_at_ms, created_at_ms
|
|
16
|
+
) VALUES (
|
|
17
|
+
:binding_id, :project_sequence, :event_type, :payload_json, :applied_at_ms, :created_at_ms
|
|
18
|
+
)
|
|
19
|
+
ON CONFLICT(binding_id, project_sequence) DO NOTHING`);
|
|
20
|
+
for (const event of events) {
|
|
21
|
+
insert.run({
|
|
22
|
+
binding_id: bindingId,
|
|
23
|
+
project_sequence: event.projectSequence,
|
|
24
|
+
event_type: event.eventType,
|
|
25
|
+
payload_json: event.payloadJson ?? null,
|
|
26
|
+
applied_at_ms: event.appliedAtMs ?? null,
|
|
27
|
+
created_at_ms: now,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function latestCachedSequence(store, bindingId) {
|
|
32
|
+
const row = store.db
|
|
33
|
+
.prepare("SELECT MAX(project_sequence) AS latest FROM cached_project_events WHERE binding_id = :binding_id")
|
|
34
|
+
.get({ binding_id: bindingId });
|
|
35
|
+
return row?.latest ?? null;
|
|
36
|
+
}
|
|
37
|
+
export function listCachedProjectEvents(store, bindingId) {
|
|
38
|
+
const rows = store.db
|
|
39
|
+
.prepare("SELECT * FROM cached_project_events WHERE binding_id = :binding_id ORDER BY project_sequence ASC")
|
|
40
|
+
.all({ binding_id: bindingId });
|
|
41
|
+
return rows.map(mapRow);
|
|
42
|
+
}
|
|
43
|
+
export function pruneProjectEvents(store, bindingId, keepCount = DEFAULT_PROJECT_EVENT_KEEP_COUNT) {
|
|
44
|
+
store.db
|
|
45
|
+
.prepare(`DELETE FROM cached_project_events
|
|
46
|
+
WHERE binding_id = :binding_id
|
|
47
|
+
AND project_sequence NOT IN (
|
|
48
|
+
SELECT project_sequence FROM cached_project_events
|
|
49
|
+
WHERE binding_id = :binding_id
|
|
50
|
+
ORDER BY project_sequence DESC
|
|
51
|
+
LIMIT :keep
|
|
52
|
+
)`)
|
|
53
|
+
.run({ binding_id: bindingId, keep: keepCount });
|
|
54
|
+
}
|