@nurix/nustack 0.10.0-dev.10
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 +77 -0
- package/dist/commands/add.js +130 -0
- package/dist/commands/agent.js +100 -0
- package/dist/commands/bootstrap.js +54 -0
- package/dist/commands/describe.js +34 -0
- package/dist/commands/device.js +159 -0
- package/dist/commands/docs.js +119 -0
- package/dist/commands/doctor.js +284 -0
- package/dist/commands/graph.js +34 -0
- package/dist/commands/handoff.js +108 -0
- package/dist/commands/init.js +280 -0
- package/dist/commands/lane.js +12 -0
- package/dist/commands/list.js +20 -0
- package/dist/commands/login.js +134 -0
- package/dist/commands/logout.js +44 -0
- package/dist/commands/open.js +54 -0
- package/dist/commands/organization.js +114 -0
- package/dist/commands/review.js +152 -0
- package/dist/commands/search.js +57 -0
- package/dist/commands/sessions.js +62 -0
- package/dist/commands/sync.js +85 -0
- package/dist/commands/telemetry.js +112 -0
- package/dist/commands/toolkit.js +167 -0
- package/dist/commands/validate.js +67 -0
- package/dist/commands/workspace-sync.js +645 -0
- package/dist/commands/workspace.js +479 -0
- package/dist/index.js +357 -0
- package/dist/lib/agent/claudeHooks.js +77 -0
- package/dist/lib/auth_grant.js +398 -0
- package/dist/lib/auth_recovery.js +49 -0
- package/dist/lib/browser_opener.js +24 -0
- package/dist/lib/device_identity.js +28 -0
- package/dist/lib/device_keychain.js +101 -0
- package/dist/lib/env_file.js +75 -0
- package/dist/lib/etna/manager.js +43 -0
- package/dist/lib/etna/profile.js +41 -0
- package/dist/lib/etna/reconcile.js +77 -0
- package/dist/lib/filebeat_installer.js +246 -0
- package/dist/lib/git-sync/core/commit_range.js +37 -0
- package/dist/lib/git-sync/core/engine.js +263 -0
- package/dist/lib/git-sync/core/git_runner.js +137 -0
- package/dist/lib/git-sync/core/operation_state.js +22 -0
- package/dist/lib/git-sync/core/path_normalizer.js +98 -0
- package/dist/lib/git-sync/core/reconciliation_lock.js +103 -0
- package/dist/lib/git-sync/core/repository.js +49 -0
- package/dist/lib/git-sync/core/watch_stream.js +86 -0
- package/dist/lib/git-sync/core/watcher.js +61 -0
- package/dist/lib/git-sync/inbound/applier.js +131 -0
- package/dist/lib/git-sync/inbound/apply_commit.js +225 -0
- package/dist/lib/git-sync/inbound/approval.js +117 -0
- package/dist/lib/git-sync/inbound/content_decision.js +148 -0
- package/dist/lib/git-sync/inbound/download.js +223 -0
- package/dist/lib/git-sync/inbound/event_sync.js +133 -0
- package/dist/lib/git-sync/inbound/index.js +10 -0
- package/dist/lib/git-sync/inbound/notification.js +39 -0
- package/dist/lib/git-sync/inbound/state_fingerprint.js +32 -0
- package/dist/lib/git-sync/inbound/technical_wait.js +39 -0
- package/dist/lib/git-sync/inbound/undo.js +198 -0
- package/dist/lib/git-sync/outbound/extraction.js +107 -0
- package/dist/lib/git-sync/outbound/history_decision.js +112 -0
- package/dist/lib/git-sync/outbound/index.js +26 -0
- package/dist/lib/git-sync/outbound/manifest_builder.js +58 -0
- package/dist/lib/git-sync/outbound/publisher.js +51 -0
- package/dist/lib/git-sync/outbound/queue.js +123 -0
- package/dist/lib/git-sync/outbound/state.js +12 -0
- package/dist/lib/git-sync/outbound/upload_client.js +141 -0
- package/dist/lib/git-sync/recovery/bundle.js +56 -0
- package/dist/lib/git-sync/recovery/crypto.js +114 -0
- package/dist/lib/git-sync/recovery/index.js +3 -0
- package/dist/lib/git-sync/recovery/store.js +55 -0
- package/dist/lib/git-sync/whitelist/index.js +4 -0
- package/dist/lib/git-sync/whitelist/matcher.js +62 -0
- package/dist/lib/git-sync/whitelist/store.js +99 -0
- package/dist/lib/git-sync/whitelist/types.js +46 -0
- package/dist/lib/git-sync/whitelist/validation.js +60 -0
- package/dist/lib/graph/answer.js +171 -0
- package/dist/lib/graph/freshness.js +29 -0
- package/dist/lib/graph/library.js +19 -0
- package/dist/lib/guards.js +106 -0
- package/dist/lib/interactive.js +12 -0
- package/dist/lib/json_output.js +8 -0
- package/dist/lib/lanes/claudeAdapter.js +135 -0
- package/dist/lib/lanes/frames.js +73 -0
- package/dist/lib/lanes/supervisor.js +247 -0
- package/dist/lib/lanes/worktree.js +33 -0
- package/dist/lib/local_store/accepted_uploads.js +49 -0
- package/dist/lib/local_store/bindings.js +163 -0
- package/dist/lib/local_store/commit_origins.js +25 -0
- package/dist/lib/local_store/database.js +71 -0
- package/dist/lib/local_store/device_state.js +72 -0
- package/dist/lib/local_store/filebeat_state.js +133 -0
- package/dist/lib/local_store/handoff_applications.js +166 -0
- package/dist/lib/local_store/history_decisions.js +48 -0
- package/dist/lib/local_store/index.js +14 -0
- package/dist/lib/local_store/migrations.js +240 -0
- package/dist/lib/local_store/paths.js +24 -0
- package/dist/lib/local_store/pending_uploads.js +76 -0
- package/dist/lib/local_store/recovery_bundles.js +66 -0
- package/dist/lib/local_store/sync_vocabulary.js +33 -0
- package/dist/lib/local_store/workspace_events_cache.js +54 -0
- package/dist/lib/loopback.js +78 -0
- package/dist/lib/manifest.js +83 -0
- package/dist/lib/nustack_client.js +547 -0
- package/dist/lib/nustack_store.js +17 -0
- package/dist/lib/onboarding/attach_existing_git.js +65 -0
- package/dist/lib/onboarding/binding.js +91 -0
- package/dist/lib/onboarding/folder_state.js +99 -0
- package/dist/lib/onboarding/git_init.js +10 -0
- package/dist/lib/onboarding/handoff_apply.js +68 -0
- package/dist/lib/onboarding/init_fresh_folder.js +100 -0
- package/dist/lib/onboarding/manifest_writer.js +161 -0
- package/dist/lib/onboarding/web_first_attach.js +133 -0
- package/dist/lib/package_manager.js +23 -0
- package/dist/lib/review/client.js +66 -0
- package/dist/lib/schema_validator.js +24 -0
- package/dist/lib/secure_file.js +10 -0
- package/dist/lib/session_telemetry.js +263 -0
- package/dist/lib/sessions/client.js +48 -0
- package/dist/lib/sessions/outsideSessions.js +54 -0
- package/dist/lib/sse_framing.js +48 -0
- package/dist/lib/telemetry/config_renderer.js +175 -0
- package/dist/lib/telemetry/health_report.js +173 -0
- package/dist/lib/telemetry/provider_paths.js +38 -0
- package/dist/lib/telemetry/redaction_policy.js +90 -0
- package/dist/lib/telemetry/reload.js +84 -0
- package/dist/lib/workspace_context.js +121 -0
- package/dist/spec_schema.json +422 -0
- package/dist/wire/auth.js +1 -0
- package/dist/wire/sync-paths.js +25 -0
- package/dist/wire/telemetry.js +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
export const WATCH_STREAM_SCHEMA_VERSION = 1;
|
|
3
|
+
const KNOWN_EVENT_TYPES = new Set([
|
|
4
|
+
"watch_started",
|
|
5
|
+
"watch_stopped",
|
|
6
|
+
"binding_status",
|
|
7
|
+
"wake",
|
|
8
|
+
"reconcile_started",
|
|
9
|
+
"reconcile_result",
|
|
10
|
+
"heartbeat",
|
|
11
|
+
"error",
|
|
12
|
+
"upload_started",
|
|
13
|
+
"upload_result",
|
|
14
|
+
"history_decision_resolved",
|
|
15
|
+
"handoff_received",
|
|
16
|
+
"handoff_evaluated",
|
|
17
|
+
"decision_required",
|
|
18
|
+
"handoff_superseded",
|
|
19
|
+
"handoff_committed",
|
|
20
|
+
"undo_result",
|
|
21
|
+
"recovery_bundle_created",
|
|
22
|
+
]);
|
|
23
|
+
export class WatchStreamRedactionError extends Error {
|
|
24
|
+
field;
|
|
25
|
+
reason;
|
|
26
|
+
constructor(field, reason) {
|
|
27
|
+
super(`watch-stream event withheld — ${reason} in field "${field}"`);
|
|
28
|
+
this.name = "WatchStreamRedactionError";
|
|
29
|
+
this.field = field;
|
|
30
|
+
this.reason = reason;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function unsafeReason(value) {
|
|
34
|
+
if (value.length === 0)
|
|
35
|
+
return null;
|
|
36
|
+
if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value))
|
|
37
|
+
return "absolute path";
|
|
38
|
+
const home = os.homedir();
|
|
39
|
+
if (home.length > 1 && value.includes(home))
|
|
40
|
+
return "home-directory prefix";
|
|
41
|
+
if (value.includes("adr_") || value.includes("adt_") || value.includes("nustack_npm_")) {
|
|
42
|
+
return "credential material";
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
export function assertWatchStreamRedacted(event, field = "$") {
|
|
47
|
+
if (typeof event === "string") {
|
|
48
|
+
const reason = unsafeReason(event);
|
|
49
|
+
if (reason)
|
|
50
|
+
throw new WatchStreamRedactionError(field, reason);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (event === null || typeof event !== "object")
|
|
54
|
+
return;
|
|
55
|
+
for (const [key, value] of Object.entries(event)) {
|
|
56
|
+
assertWatchStreamRedacted(value, `${field}.${key}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function createWatchStreamEmitter(writable) {
|
|
60
|
+
return {
|
|
61
|
+
emit(input) {
|
|
62
|
+
const event = {
|
|
63
|
+
v: WATCH_STREAM_SCHEMA_VERSION,
|
|
64
|
+
at: new Date().toISOString(),
|
|
65
|
+
...input,
|
|
66
|
+
};
|
|
67
|
+
assertWatchStreamRedacted(event);
|
|
68
|
+
writable.write(`${JSON.stringify(event)}\n`);
|
|
69
|
+
return event;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function isWatchStreamEnvelope(value) {
|
|
74
|
+
if (value === null || typeof value !== "object")
|
|
75
|
+
return false;
|
|
76
|
+
const record = value;
|
|
77
|
+
return (typeof record.v === "number" &&
|
|
78
|
+
typeof record.event === "string" &&
|
|
79
|
+
typeof record.at === "string");
|
|
80
|
+
}
|
|
81
|
+
export function isCompatibleWatchStreamVersion(value) {
|
|
82
|
+
return isWatchStreamEnvelope(value) && value.v === WATCH_STREAM_SCHEMA_VERSION;
|
|
83
|
+
}
|
|
84
|
+
export function isWatchStreamEvent(value) {
|
|
85
|
+
return isCompatibleWatchStreamVersion(value) && KNOWN_EVENT_TYPES.has(value.event);
|
|
86
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { watch as fsWatch, existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const DEFAULT_DEBOUNCE_MS = 250;
|
|
4
|
+
const REWATCH_DELAY_MS = 50;
|
|
5
|
+
export function watchGitControlPaths(repo, boundBranch, onWake, options = {}) {
|
|
6
|
+
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
7
|
+
const watchers = new Set();
|
|
8
|
+
let timer;
|
|
9
|
+
let closed = false;
|
|
10
|
+
const scheduleWake = () => {
|
|
11
|
+
if (closed)
|
|
12
|
+
return;
|
|
13
|
+
if (timer)
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
timer = setTimeout(() => {
|
|
16
|
+
timer = undefined;
|
|
17
|
+
if (!closed)
|
|
18
|
+
onWake({ source: "fs" });
|
|
19
|
+
}, debounceMs);
|
|
20
|
+
};
|
|
21
|
+
const watchTarget = (target, recursive) => {
|
|
22
|
+
if (closed || !existsSync(target))
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
const watcher = fsWatch(target, { recursive, persistent: false }, () => scheduleWake());
|
|
26
|
+
watcher.on("error", () => {
|
|
27
|
+
watchers.delete(watcher);
|
|
28
|
+
try {
|
|
29
|
+
watcher.close();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
}
|
|
33
|
+
if (!closed)
|
|
34
|
+
setTimeout(() => watchTarget(target, recursive), REWATCH_DELAY_MS);
|
|
35
|
+
});
|
|
36
|
+
watchers.add(watcher);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
watchTarget(repo.gitDir, false);
|
|
42
|
+
if (repo.commonDir !== repo.gitDir)
|
|
43
|
+
watchTarget(repo.commonDir, false);
|
|
44
|
+
watchTarget(path.join(repo.commonDir, "refs", "heads"), process.platform === "darwin");
|
|
45
|
+
watchTarget(path.join(repo.commonDir, "refs", "heads", boundBranch), false);
|
|
46
|
+
return {
|
|
47
|
+
close() {
|
|
48
|
+
closed = true;
|
|
49
|
+
if (timer)
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
for (const watcher of watchers) {
|
|
52
|
+
try {
|
|
53
|
+
watcher.close();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
watchers.clear();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { withReconciliationLock } from "../core/reconciliation_lock.js";
|
|
2
|
+
import { resolveRepository, readHeadCommit } from "../core/repository.js";
|
|
3
|
+
import { runGit } from "../core/git_runner.js";
|
|
4
|
+
import { getBindingById } from "../../local_store/bindings.js";
|
|
5
|
+
import { getApplication, upsertApplication, transitionApplicationState, listApplicationsForBinding, } from "../../local_store/handoff_applications.js";
|
|
6
|
+
import { syncBindingEvents, advanceContiguousCursor } from "./event_sync.js";
|
|
7
|
+
import { downloadHandoff } from "./download.js";
|
|
8
|
+
import { evaluateRepositoryTechnicalWait } from "./technical_wait.js";
|
|
9
|
+
import { decideHandoffContent } from "./content_decision.js";
|
|
10
|
+
import { computeStateFingerprint } from "./state_fingerprint.js";
|
|
11
|
+
import { applyCommit, finalizeCommitted } from "./apply_commit.js";
|
|
12
|
+
import { toDownloadBinding, serializeDecisionEvidence } from "./approval.js";
|
|
13
|
+
import { postResultReport } from "./notification.js";
|
|
14
|
+
import { sweepExpiredBundles } from "../recovery/store.js";
|
|
15
|
+
const RECOVERABLE_STATES = ["evaluating", "approved", "applying", "decision_required"];
|
|
16
|
+
function commitLanded(repo, commitId, head) {
|
|
17
|
+
if (!head)
|
|
18
|
+
return false;
|
|
19
|
+
return runGit(repo.repositoryRoot, ["merge-base", "--is-ancestor", commitId, head], { allowFailure: true }).exitCode === 0;
|
|
20
|
+
}
|
|
21
|
+
function recoverInterruptedApplies(store, repo, bindingId) {
|
|
22
|
+
const head = readHeadCommit(repo);
|
|
23
|
+
for (const application of listApplicationsForBinding(store, bindingId, RECOVERABLE_STATES)) {
|
|
24
|
+
const commitId = application.generatedCommitId;
|
|
25
|
+
if (commitId && commitLanded(repo, commitId, head)) {
|
|
26
|
+
finalizeCommitted(store, bindingId, application.handoffId, commitId, application.approvedByUserId);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function runInboundReconciliation(store, runtime, bindingId) {
|
|
31
|
+
const binding = getBindingById(store, bindingId);
|
|
32
|
+
if (!binding)
|
|
33
|
+
return;
|
|
34
|
+
const repo = resolveRepository(binding.repositoryRoot);
|
|
35
|
+
recoverInterruptedApplies(store, repo, bindingId);
|
|
36
|
+
await sweepExpiredBundles(store, runtime.now ? runtime.now() : Date.now());
|
|
37
|
+
const eventDeps = { baseUrl: runtime.baseUrl, accessToken: runtime.accessToken, ...runtime.eventDeps };
|
|
38
|
+
await syncBindingEvents(store, eventDeps, bindingId);
|
|
39
|
+
for (const application of listApplicationsForBinding(store, bindingId, ["received", "evaluating"])) {
|
|
40
|
+
await evaluateApplication(store, runtime, binding, repo, application.handoffId);
|
|
41
|
+
}
|
|
42
|
+
await advanceContiguousCursor(store, eventDeps, bindingId);
|
|
43
|
+
}
|
|
44
|
+
async function evaluateApplication(store, runtime, binding, repo, handoffId) {
|
|
45
|
+
const bindingId = binding.id;
|
|
46
|
+
const current = getApplication(store, handoffId, bindingId);
|
|
47
|
+
if (!current)
|
|
48
|
+
return;
|
|
49
|
+
if (current.state === "received")
|
|
50
|
+
transitionApplicationState(store, handoffId, bindingId, "evaluating");
|
|
51
|
+
const downloadDeps = { baseUrl: runtime.baseUrl, accessToken: runtime.accessToken, ...runtime.downloadDeps };
|
|
52
|
+
const download = await downloadHandoff(downloadDeps, toDownloadBinding(binding), handoffId);
|
|
53
|
+
if (!download.ok) {
|
|
54
|
+
if (download.kind === "technical") {
|
|
55
|
+
upsertApplication(store, { handoffId, bindingId, technicalWaitReason: download.reason });
|
|
56
|
+
transitionApplicationState(store, handoffId, bindingId, "technical_wait");
|
|
57
|
+
runtime.emitter?.emit({ event: "handoff_evaluated", bindingId, handoffId, outcome: "technical_wait", waitReason: download.reason });
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
upsertApplication(store, { handoffId, bindingId, errorCode: download.code, errorDetail: download.detail, technicalWaitReason: download.code });
|
|
61
|
+
transitionApplicationState(store, handoffId, bindingId, "technical_wait");
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const { manifest, targets } = download;
|
|
66
|
+
upsertApplication(store, { handoffId, bindingId, manifestSha256: manifest.manifestSha256 });
|
|
67
|
+
const repoWait = evaluateRepositoryTechnicalWait(repo, binding.boundBranch);
|
|
68
|
+
if (repoWait.wait) {
|
|
69
|
+
upsertApplication(store, { handoffId, bindingId, technicalWaitReason: repoWait.reason });
|
|
70
|
+
transitionApplicationState(store, handoffId, bindingId, "technical_wait");
|
|
71
|
+
runtime.emitter?.emit({ event: "handoff_evaluated", bindingId, handoffId, outcome: "technical_wait", waitReason: repoWait.reason });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const decision = decideHandoffContent(repo, targets);
|
|
75
|
+
const targetPaths = targets.map((target) => target.path);
|
|
76
|
+
if (decision.kind === "no_op") {
|
|
77
|
+
transitionApplicationState(store, handoffId, bindingId, "no_op");
|
|
78
|
+
runtime.emitter?.emit({ event: "handoff_evaluated", bindingId, handoffId, outcome: "no_op" });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (decision.kind === "decision_required") {
|
|
82
|
+
const fingerprint = computeStateFingerprint(repo, targetPaths, manifest.manifestSha256);
|
|
83
|
+
upsertApplication(store, {
|
|
84
|
+
handoffId,
|
|
85
|
+
bindingId,
|
|
86
|
+
localStateFingerprint: fingerprint,
|
|
87
|
+
decisionState: serializeDecisionEvidence(decision.divergent, targetPaths),
|
|
88
|
+
});
|
|
89
|
+
transitionApplicationState(store, handoffId, bindingId, "decision_required");
|
|
90
|
+
runtime.emitter?.emit({ event: "handoff_evaluated", bindingId, handoffId, outcome: "decision_required" });
|
|
91
|
+
runtime.emitter?.emit({
|
|
92
|
+
event: "decision_required",
|
|
93
|
+
bindingId,
|
|
94
|
+
handoffId,
|
|
95
|
+
paths: decision.divergent.map((entry) => entry.path),
|
|
96
|
+
divergenceClasses: decision.divergent.map((entry) => entry.divergence),
|
|
97
|
+
fingerprintAt: new Date(runtime.now ? runtime.now() : Date.now()).toISOString(),
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
runtime.emitter?.emit({ event: "handoff_evaluated", bindingId, handoffId, outcome: "automatic" });
|
|
102
|
+
const result = await applyCommit({ store, repo, binding, manifest, targets, approved: false, emitter: runtime.emitter });
|
|
103
|
+
if (result.ok) {
|
|
104
|
+
await postResultReport({ baseUrl: runtime.baseUrl, accessToken: runtime.accessToken }, binding, { handoffId, commitId: result.commitId, status: "committed", manifestHash: manifest.manifestSha256, affectedPaths: result.changedPaths });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function reconcileInboundBinding(store, runtime, bindingId) {
|
|
108
|
+
return withReconciliationLock(bindingId, () => runInboundReconciliation(store, runtime, bindingId));
|
|
109
|
+
}
|
|
110
|
+
export function createInboundApplier(runtime) {
|
|
111
|
+
return (context) => runInboundReconciliation(context.store, runtime, context.binding.id);
|
|
112
|
+
}
|
|
113
|
+
export function registerInboundApplier(engine, runtime) {
|
|
114
|
+
engine.registerInboundApplier((context) => runInboundReconciliation(context.store, runtime, context.binding.id));
|
|
115
|
+
}
|
|
116
|
+
export async function retryApplication(store, runtime, bindingId, handoffId) {
|
|
117
|
+
return withReconciliationLock(bindingId, async () => {
|
|
118
|
+
const application = getApplication(store, handoffId, bindingId);
|
|
119
|
+
if (!application)
|
|
120
|
+
return { ok: false, code: "not_found" };
|
|
121
|
+
if (application.state === "technical_wait") {
|
|
122
|
+
upsertApplication(store, { handoffId, bindingId, errorCode: null, errorDetail: null, technicalWaitReason: null });
|
|
123
|
+
transitionApplicationState(store, handoffId, bindingId, "evaluating");
|
|
124
|
+
}
|
|
125
|
+
else if (application.state !== "evaluating" && application.state !== "received") {
|
|
126
|
+
return { ok: false, code: "not_retryable" };
|
|
127
|
+
}
|
|
128
|
+
await runInboundReconciliation(store, runtime, bindingId);
|
|
129
|
+
return { ok: true, state: getApplication(store, handoffId, bindingId)?.state };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstatSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync, mkdirSync, symlinkSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { runGit } from "../core/git_runner.js";
|
|
6
|
+
import { readHeadCommit } from "../core/repository.js";
|
|
7
|
+
import { safeJoin } from "../core/path_normalizer.js";
|
|
8
|
+
import { recordInboundCommit } from "../../local_store/commit_origins.js";
|
|
9
|
+
import { getApplication, upsertApplication, transitionApplicationState, } from "../../local_store/handoff_applications.js";
|
|
10
|
+
import { readTargetState } from "./content_decision.js";
|
|
11
|
+
import { computeStateFingerprint } from "./state_fingerprint.js";
|
|
12
|
+
import { persistRecoveryBundle } from "../recovery/store.js";
|
|
13
|
+
export const NUSTACK_SYNC_IDENTITY = { name: "NuStack Sync", email: "sync@nustack.local" };
|
|
14
|
+
export function buildCommitMessage(handoffId, workspaceId, approvedByUserId) {
|
|
15
|
+
const lines = [
|
|
16
|
+
`nustack(sync): apply cloud handoff ${handoffId.slice(0, 12)}`,
|
|
17
|
+
"",
|
|
18
|
+
`NuStack-Handoff: ${handoffId}`,
|
|
19
|
+
`NuStack-Workspace: ${workspaceId}`,
|
|
20
|
+
"NuStack-Origin: cloud",
|
|
21
|
+
];
|
|
22
|
+
if (approvedByUserId)
|
|
23
|
+
lines.push(`NuStack-Approved-By: ${approvedByUserId}`);
|
|
24
|
+
return `${lines.join("\n")}\n`;
|
|
25
|
+
}
|
|
26
|
+
export function captureTarget(repo, targetPath) {
|
|
27
|
+
const state = readTargetState(repo, targetPath);
|
|
28
|
+
const indexEntries = state.index.filter((entry) => entry.stage === 0);
|
|
29
|
+
const abs = path.join(repo.repositoryRoot, targetPath);
|
|
30
|
+
let working = { type: "absent" };
|
|
31
|
+
try {
|
|
32
|
+
const stat = lstatSync(abs);
|
|
33
|
+
if (stat.isSymbolicLink())
|
|
34
|
+
working = { type: "symlink", link: readlinkSync(abs) };
|
|
35
|
+
else if (stat.isFile())
|
|
36
|
+
working = { type: "file", bytes: readFileSync(abs) };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
working = { type: "absent" };
|
|
40
|
+
}
|
|
41
|
+
return { path: targetPath, indexEntries, working };
|
|
42
|
+
}
|
|
43
|
+
export function buildCommit(repo, oldHead, targets, bytesByPath, message, identity) {
|
|
44
|
+
const tmpIndex = path.join(os.tmpdir(), `nustack-idx-${randomUUID()}`);
|
|
45
|
+
const blobByPath = new Map();
|
|
46
|
+
try {
|
|
47
|
+
if (oldHead)
|
|
48
|
+
runGit(repo.repositoryRoot, ["read-tree", oldHead], { indexFile: tmpIndex });
|
|
49
|
+
for (const target of targets) {
|
|
50
|
+
if (target.operation === "delete") {
|
|
51
|
+
runGit(repo.repositoryRoot, ["update-index", "--force-remove", "--", target.path], { indexFile: tmpIndex });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const bytes = bytesByPath.get(target.path);
|
|
55
|
+
const blob = runGit(repo.repositoryRoot, ["hash-object", "-w", "--stdin"], { input: bytes }).stdout.trim();
|
|
56
|
+
blobByPath.set(target.path, blob);
|
|
57
|
+
runGit(repo.repositoryRoot, ["update-index", "--add", "--cacheinfo", `100644,${blob},${target.path}`], { indexFile: tmpIndex });
|
|
58
|
+
}
|
|
59
|
+
const tree = runGit(repo.repositoryRoot, ["write-tree"], { indexFile: tmpIndex }).stdout.trim();
|
|
60
|
+
const commitArgs = ["commit-tree", tree];
|
|
61
|
+
if (oldHead)
|
|
62
|
+
commitArgs.push("-p", oldHead);
|
|
63
|
+
commitArgs.push("-m", message);
|
|
64
|
+
const commitId = runGit(repo.repositoryRoot, commitArgs, { identity }).stdout.trim();
|
|
65
|
+
return { commitId, blobByPath };
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
rmSync(tmpIndex, { force: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function atomicWrite(repo, targetPath, bytes) {
|
|
72
|
+
const joined = safeJoin(repo.repositoryRoot, targetPath);
|
|
73
|
+
if (!joined.ok)
|
|
74
|
+
throw new Error(`refusing to write outside the repository root: ${targetPath}`);
|
|
75
|
+
const dir = path.dirname(joined.path);
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
const tmp = path.join(dir, `.nustack-tmp-${randomUUID()}`);
|
|
78
|
+
writeFileSync(tmp, bytes, { mode: 0o644 });
|
|
79
|
+
renameSync(tmp, joined.path);
|
|
80
|
+
}
|
|
81
|
+
export function materializeTarget(repo, target, blob, bytes) {
|
|
82
|
+
if (target.operation === "delete") {
|
|
83
|
+
runGit(repo.repositoryRoot, ["update-index", "--force-remove", "--", target.path]);
|
|
84
|
+
rmSync(path.join(repo.repositoryRoot, target.path), { force: true });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
runGit(repo.repositoryRoot, ["update-index", "--add", "--cacheinfo", `100644,${blob},${target.path}`]);
|
|
88
|
+
atomicWrite(repo, target.path, bytes);
|
|
89
|
+
}
|
|
90
|
+
export function restoreTarget(repo, captured) {
|
|
91
|
+
runGit(repo.repositoryRoot, ["update-index", "--force-remove", "--", captured.path]);
|
|
92
|
+
for (const entry of captured.indexEntries) {
|
|
93
|
+
runGit(repo.repositoryRoot, ["update-index", "--add", "--cacheinfo", `${entry.mode},${entry.blobSha},${captured.path}`]);
|
|
94
|
+
}
|
|
95
|
+
const abs = path.join(repo.repositoryRoot, captured.path);
|
|
96
|
+
if (captured.working.type === "absent") {
|
|
97
|
+
rmSync(abs, { force: true });
|
|
98
|
+
}
|
|
99
|
+
else if (captured.working.type === "file") {
|
|
100
|
+
atomicWrite(repo, captured.path, captured.working.bytes);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
rmSync(abs, { force: true });
|
|
104
|
+
mkdirSync(path.dirname(abs), { recursive: true });
|
|
105
|
+
symlinkSync(captured.working.link, abs);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function casBranchRef(repo, branch, newCommit, oldHead) {
|
|
109
|
+
const ref = `refs/heads/${branch}`;
|
|
110
|
+
const args = oldHead ? ["update-ref", ref, newCommit, oldHead] : ["update-ref", ref, newCommit];
|
|
111
|
+
return runGit(repo.repositoryRoot, args, { allowFailure: true }).exitCode === 0;
|
|
112
|
+
}
|
|
113
|
+
function toBundleContent(captured, manifestSha256, stateFingerprint, handoffId, bindingId) {
|
|
114
|
+
const targets = captured.map((entry) => ({
|
|
115
|
+
path: entry.path,
|
|
116
|
+
workingContentBase64: entry.working.type === "file"
|
|
117
|
+
? entry.working.bytes.toString("base64")
|
|
118
|
+
: entry.working.type === "symlink"
|
|
119
|
+
? Buffer.from(entry.working.link, "utf8").toString("base64")
|
|
120
|
+
: null,
|
|
121
|
+
workingType: entry.working.type,
|
|
122
|
+
indexEntries: entry.indexEntries.map((e) => ({ mode: e.mode, blobSha: e.blobSha, stage: e.stage })),
|
|
123
|
+
}));
|
|
124
|
+
return {
|
|
125
|
+
version: 1,
|
|
126
|
+
bindingId,
|
|
127
|
+
handoffId,
|
|
128
|
+
manifestSha256,
|
|
129
|
+
stateFingerprint,
|
|
130
|
+
capturedAtMs: Date.now(),
|
|
131
|
+
targets,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export function finalizeCommitted(store, bindingId, handoffId, commitId, approvedByUserId) {
|
|
135
|
+
const application = getApplication(store, handoffId, bindingId);
|
|
136
|
+
if (!application)
|
|
137
|
+
return;
|
|
138
|
+
upsertApplication(store, {
|
|
139
|
+
handoffId,
|
|
140
|
+
bindingId,
|
|
141
|
+
generatedCommitId: commitId,
|
|
142
|
+
notificationState: "unacknowledged",
|
|
143
|
+
...(approvedByUserId ? { approvedByUserId } : {}),
|
|
144
|
+
});
|
|
145
|
+
if (getApplication(store, handoffId, bindingId).state === "decision_required") {
|
|
146
|
+
transitionApplicationState(store, handoffId, bindingId, "approved");
|
|
147
|
+
}
|
|
148
|
+
const beforeApplying = getApplication(store, handoffId, bindingId).state;
|
|
149
|
+
if (beforeApplying === "evaluating" || beforeApplying === "approved") {
|
|
150
|
+
transitionApplicationState(store, handoffId, bindingId, "applying");
|
|
151
|
+
}
|
|
152
|
+
recordInboundCommit(store, { bindingId, commitId, handoffId });
|
|
153
|
+
if (getApplication(store, handoffId, bindingId).state === "applying") {
|
|
154
|
+
transitionApplicationState(store, handoffId, bindingId, "committed");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function applyCommit(input) {
|
|
158
|
+
const { store, repo, binding, manifest, targets, approved, emitter } = input;
|
|
159
|
+
const handoffId = manifest.handoffId;
|
|
160
|
+
const oldHead = readHeadCommit(repo);
|
|
161
|
+
const captured = targets.map((target) => captureTarget(repo, target.path));
|
|
162
|
+
let recoveryBundleId = null;
|
|
163
|
+
if (approved && input.recovery) {
|
|
164
|
+
const fingerprint = computeStateFingerprint(repo, targets.map((t) => t.path), manifest.manifestSha256);
|
|
165
|
+
const content = toBundleContent(captured, manifest.manifestSha256, fingerprint, handoffId, binding.id);
|
|
166
|
+
const persisted = await persistRecoveryBundle({
|
|
167
|
+
store,
|
|
168
|
+
deviceRef: input.recovery.deviceRef,
|
|
169
|
+
bindingId: binding.id,
|
|
170
|
+
handoffId,
|
|
171
|
+
manifestSha256: manifest.manifestSha256,
|
|
172
|
+
content,
|
|
173
|
+
});
|
|
174
|
+
recoveryBundleId = persisted.bundle.id;
|
|
175
|
+
upsertApplication(store, { handoffId, bindingId: binding.id, recoveryBundleId });
|
|
176
|
+
emitter?.emit({
|
|
177
|
+
event: "recovery_bundle_created",
|
|
178
|
+
bindingId: binding.id,
|
|
179
|
+
handoffId,
|
|
180
|
+
bundleId: recoveryBundleId,
|
|
181
|
+
expiresAt: new Date(persisted.bundle.expiresAtMs).toISOString(),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const bytesByPath = new Map();
|
|
185
|
+
for (const target of targets) {
|
|
186
|
+
if (target.operation === "upsert" && target.blobPath)
|
|
187
|
+
bytesByPath.set(target.path, readFileSync(target.blobPath));
|
|
188
|
+
}
|
|
189
|
+
const message = buildCommitMessage(handoffId, manifest.workspaceId, approved ? (input.approvedByUserId ?? null) : null);
|
|
190
|
+
const built = buildCommit(repo, oldHead, targets, bytesByPath, message, {
|
|
191
|
+
authorName: NUSTACK_SYNC_IDENTITY.name,
|
|
192
|
+
authorEmail: NUSTACK_SYNC_IDENTITY.email,
|
|
193
|
+
committerName: NUSTACK_SYNC_IDENTITY.name,
|
|
194
|
+
committerEmail: NUSTACK_SYNC_IDENTITY.email,
|
|
195
|
+
...(input.authorDate ? { authorDate: input.authorDate } : {}),
|
|
196
|
+
...(input.committerDate ? { committerDate: input.committerDate } : {}),
|
|
197
|
+
});
|
|
198
|
+
upsertApplication(store, { handoffId, bindingId: binding.id, generatedCommitId: built.commitId });
|
|
199
|
+
for (const target of targets) {
|
|
200
|
+
materializeTarget(repo, target, built.blobByPath.get(target.path) ?? null, bytesByPath.get(target.path) ?? null);
|
|
201
|
+
}
|
|
202
|
+
if (!casBranchRef(repo, binding.boundBranch, built.commitId, oldHead)) {
|
|
203
|
+
for (const capture of captured)
|
|
204
|
+
restoreTarget(repo, capture);
|
|
205
|
+
upsertApplication(store, {
|
|
206
|
+
handoffId,
|
|
207
|
+
bindingId: binding.id,
|
|
208
|
+
generatedCommitId: null,
|
|
209
|
+
errorCode: "cas_conflict",
|
|
210
|
+
errorDetail: "the branch ref moved during apply",
|
|
211
|
+
});
|
|
212
|
+
return { ok: false, kind: "cas_conflict" };
|
|
213
|
+
}
|
|
214
|
+
finalizeCommitted(store, binding.id, handoffId, built.commitId, approved ? (input.approvedByUserId ?? null) : null);
|
|
215
|
+
const changedPaths = targets.map((target) => target.path);
|
|
216
|
+
emitter?.emit({
|
|
217
|
+
event: "handoff_committed",
|
|
218
|
+
bindingId: binding.id,
|
|
219
|
+
handoffId,
|
|
220
|
+
commitId: built.commitId,
|
|
221
|
+
paths: changedPaths,
|
|
222
|
+
noPush: true,
|
|
223
|
+
});
|
|
224
|
+
return { ok: true, commitId: built.commitId, changedPaths, recoveryBundleId };
|
|
225
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { withReconciliationLock } from "../core/reconciliation_lock.js";
|
|
2
|
+
import { resolveRepository } from "../core/repository.js";
|
|
3
|
+
import { getBindingById, } from "../../local_store/bindings.js";
|
|
4
|
+
import { getApplication, upsertApplication, transitionApplicationState, } from "../../local_store/handoff_applications.js";
|
|
5
|
+
import { reportApplicationDecision, } from "../../nustack_client.js";
|
|
6
|
+
import { downloadHandoff } from "./download.js";
|
|
7
|
+
import { decideHandoffContent } from "./content_decision.js";
|
|
8
|
+
import { computeStateFingerprint } from "./state_fingerprint.js";
|
|
9
|
+
import { applyCommit } from "./apply_commit.js";
|
|
10
|
+
import { postResultReport } from "./notification.js";
|
|
11
|
+
export const APPROVAL_RECOVERY_NOTE = "Approving overwrites the listed paths. Git Undo restores the prior committed base; the encrypted recovery bundle is the only path back to your pre-approval uncommitted content.";
|
|
12
|
+
export function toDownloadBinding(binding) {
|
|
13
|
+
return {
|
|
14
|
+
id: binding.id,
|
|
15
|
+
workspaceId: binding.workspaceId,
|
|
16
|
+
boundBranch: binding.boundBranch,
|
|
17
|
+
repositoryRoot: binding.repositoryRoot,
|
|
18
|
+
etnaProfileJson: binding.etnaProfileJson,
|
|
19
|
+
whitelistManifestSha256: binding.whitelistManifestSha256,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function serializeDecisionEvidence(divergent, targetPaths) {
|
|
23
|
+
return JSON.stringify({ divergent, targetPaths });
|
|
24
|
+
}
|
|
25
|
+
export async function approveHandoff(store, deps, bindingId, handoffId) {
|
|
26
|
+
return withReconciliationLock(bindingId, async () => {
|
|
27
|
+
const binding = getBindingById(store, bindingId);
|
|
28
|
+
if (!binding)
|
|
29
|
+
return { ok: false, code: "not_found" };
|
|
30
|
+
const application = getApplication(store, handoffId, bindingId);
|
|
31
|
+
if (!application)
|
|
32
|
+
return { ok: false, code: "not_found" };
|
|
33
|
+
if (application.state === "superseded")
|
|
34
|
+
return { ok: false, code: "superseded" };
|
|
35
|
+
if (application.state !== "decision_required")
|
|
36
|
+
return { ok: false, code: "not_pending" };
|
|
37
|
+
const repo = resolveRepository(binding.repositoryRoot);
|
|
38
|
+
const downloadDeps = { baseUrl: deps.baseUrl, accessToken: deps.accessToken, ...deps.downloadDeps };
|
|
39
|
+
const download = await downloadHandoff(downloadDeps, toDownloadBinding(binding), handoffId);
|
|
40
|
+
if (!download.ok) {
|
|
41
|
+
return download.kind === "technical"
|
|
42
|
+
? { ok: false, code: "technical_wait", detail: download.reason }
|
|
43
|
+
: { ok: false, code: "download_rejected", detail: download.code };
|
|
44
|
+
}
|
|
45
|
+
const { manifest, targets } = download;
|
|
46
|
+
const targetPaths = targets.map((target) => target.path);
|
|
47
|
+
const currentFingerprint = computeStateFingerprint(repo, targetPaths, manifest.manifestSha256);
|
|
48
|
+
if (application.localStateFingerprint !== null && currentFingerprint !== application.localStateFingerprint) {
|
|
49
|
+
const decision = decideHandoffContent(repo, targets);
|
|
50
|
+
const divergent = decision.kind === "decision_required" ? decision.divergent : [];
|
|
51
|
+
upsertApplication(store, {
|
|
52
|
+
handoffId,
|
|
53
|
+
bindingId,
|
|
54
|
+
localStateFingerprint: currentFingerprint,
|
|
55
|
+
decisionState: serializeDecisionEvidence(divergent, targetPaths),
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
code: "local_state_changed",
|
|
60
|
+
warning: { handoffId, bindingId, paths: divergent, fingerprint: currentFingerprint, recoveryNote: APPROVAL_RECOVERY_NOTE },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const decision = decideHandoffContent(repo, targets);
|
|
64
|
+
if (decision.kind !== "decision_required") {
|
|
65
|
+
const result = await applyCommit({ store, repo, binding, manifest, targets, approved: false, emitter: deps.emitter });
|
|
66
|
+
return finishApply(deps, binding, handoffId, manifest.manifestSha256, result);
|
|
67
|
+
}
|
|
68
|
+
const decisionReport = {
|
|
69
|
+
bindingId,
|
|
70
|
+
handoffId,
|
|
71
|
+
idempotencyKey: `decision:${bindingId}:${handoffId}`,
|
|
72
|
+
decision: "approve_overwrite",
|
|
73
|
+
localStateFingerprint: currentFingerprint,
|
|
74
|
+
affectedPaths: decision.divergent.map((entry) => entry.path),
|
|
75
|
+
};
|
|
76
|
+
const reportDecision = deps.reportDecision ?? reportApplicationDecision;
|
|
77
|
+
let queued = false;
|
|
78
|
+
try {
|
|
79
|
+
const outcome = await reportDecision(deps.baseUrl, deps.accessToken, decisionReport);
|
|
80
|
+
if (!outcome.ok) {
|
|
81
|
+
transitionApplicationState(store, handoffId, bindingId, "superseded");
|
|
82
|
+
return { ok: false, code: "superseded" };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
queued = true;
|
|
87
|
+
}
|
|
88
|
+
void queued;
|
|
89
|
+
const result = await applyCommit({
|
|
90
|
+
store,
|
|
91
|
+
repo,
|
|
92
|
+
binding,
|
|
93
|
+
manifest,
|
|
94
|
+
targets,
|
|
95
|
+
approved: true,
|
|
96
|
+
approvedByUserId: deps.userId,
|
|
97
|
+
recovery: deps.deviceRef ? { deviceRef: deps.deviceRef } : null,
|
|
98
|
+
emitter: deps.emitter,
|
|
99
|
+
});
|
|
100
|
+
return finishApply(deps, binding, handoffId, manifest.manifestSha256, result);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async function finishApply(deps, binding, handoffId, manifestSha256, result) {
|
|
104
|
+
if (result.ok) {
|
|
105
|
+
await postResultReport({ baseUrl: deps.baseUrl, accessToken: deps.accessToken, reportResult: deps.reportResult }, binding, {
|
|
106
|
+
handoffId,
|
|
107
|
+
commitId: result.commitId,
|
|
108
|
+
status: "committed",
|
|
109
|
+
manifestHash: manifestSha256,
|
|
110
|
+
affectedPaths: result.changedPaths,
|
|
111
|
+
});
|
|
112
|
+
return { ok: true, commitId: result.commitId, changedPaths: result.changedPaths, recoveryBundleId: result.recoveryBundleId };
|
|
113
|
+
}
|
|
114
|
+
if (result.kind === "cas_conflict")
|
|
115
|
+
return { ok: false, code: "cas_conflict" };
|
|
116
|
+
return { ok: false, code: "download_rejected", detail: result.detail };
|
|
117
|
+
}
|