@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.
Files changed (131) hide show
  1. package/README.md +77 -0
  2. package/dist/commands/add.js +130 -0
  3. package/dist/commands/agent.js +100 -0
  4. package/dist/commands/bootstrap.js +54 -0
  5. package/dist/commands/describe.js +34 -0
  6. package/dist/commands/device.js +159 -0
  7. package/dist/commands/docs.js +119 -0
  8. package/dist/commands/doctor.js +284 -0
  9. package/dist/commands/graph.js +34 -0
  10. package/dist/commands/handoff.js +108 -0
  11. package/dist/commands/init.js +280 -0
  12. package/dist/commands/lane.js +12 -0
  13. package/dist/commands/list.js +20 -0
  14. package/dist/commands/login.js +134 -0
  15. package/dist/commands/logout.js +44 -0
  16. package/dist/commands/open.js +54 -0
  17. package/dist/commands/organization.js +114 -0
  18. package/dist/commands/review.js +152 -0
  19. package/dist/commands/search.js +57 -0
  20. package/dist/commands/sessions.js +62 -0
  21. package/dist/commands/sync.js +85 -0
  22. package/dist/commands/telemetry.js +112 -0
  23. package/dist/commands/toolkit.js +167 -0
  24. package/dist/commands/validate.js +67 -0
  25. package/dist/commands/workspace-sync.js +645 -0
  26. package/dist/commands/workspace.js +479 -0
  27. package/dist/index.js +357 -0
  28. package/dist/lib/agent/claudeHooks.js +77 -0
  29. package/dist/lib/auth_grant.js +398 -0
  30. package/dist/lib/auth_recovery.js +49 -0
  31. package/dist/lib/browser_opener.js +24 -0
  32. package/dist/lib/device_identity.js +28 -0
  33. package/dist/lib/device_keychain.js +101 -0
  34. package/dist/lib/env_file.js +75 -0
  35. package/dist/lib/etna/manager.js +43 -0
  36. package/dist/lib/etna/profile.js +41 -0
  37. package/dist/lib/etna/reconcile.js +77 -0
  38. package/dist/lib/filebeat_installer.js +246 -0
  39. package/dist/lib/git-sync/core/commit_range.js +37 -0
  40. package/dist/lib/git-sync/core/engine.js +263 -0
  41. package/dist/lib/git-sync/core/git_runner.js +137 -0
  42. package/dist/lib/git-sync/core/operation_state.js +22 -0
  43. package/dist/lib/git-sync/core/path_normalizer.js +98 -0
  44. package/dist/lib/git-sync/core/reconciliation_lock.js +103 -0
  45. package/dist/lib/git-sync/core/repository.js +49 -0
  46. package/dist/lib/git-sync/core/watch_stream.js +86 -0
  47. package/dist/lib/git-sync/core/watcher.js +61 -0
  48. package/dist/lib/git-sync/inbound/applier.js +131 -0
  49. package/dist/lib/git-sync/inbound/apply_commit.js +225 -0
  50. package/dist/lib/git-sync/inbound/approval.js +117 -0
  51. package/dist/lib/git-sync/inbound/content_decision.js +148 -0
  52. package/dist/lib/git-sync/inbound/download.js +223 -0
  53. package/dist/lib/git-sync/inbound/event_sync.js +133 -0
  54. package/dist/lib/git-sync/inbound/index.js +10 -0
  55. package/dist/lib/git-sync/inbound/notification.js +39 -0
  56. package/dist/lib/git-sync/inbound/state_fingerprint.js +32 -0
  57. package/dist/lib/git-sync/inbound/technical_wait.js +39 -0
  58. package/dist/lib/git-sync/inbound/undo.js +198 -0
  59. package/dist/lib/git-sync/outbound/extraction.js +107 -0
  60. package/dist/lib/git-sync/outbound/history_decision.js +112 -0
  61. package/dist/lib/git-sync/outbound/index.js +26 -0
  62. package/dist/lib/git-sync/outbound/manifest_builder.js +58 -0
  63. package/dist/lib/git-sync/outbound/publisher.js +51 -0
  64. package/dist/lib/git-sync/outbound/queue.js +123 -0
  65. package/dist/lib/git-sync/outbound/state.js +12 -0
  66. package/dist/lib/git-sync/outbound/upload_client.js +141 -0
  67. package/dist/lib/git-sync/recovery/bundle.js +56 -0
  68. package/dist/lib/git-sync/recovery/crypto.js +114 -0
  69. package/dist/lib/git-sync/recovery/index.js +3 -0
  70. package/dist/lib/git-sync/recovery/store.js +55 -0
  71. package/dist/lib/git-sync/whitelist/index.js +4 -0
  72. package/dist/lib/git-sync/whitelist/matcher.js +62 -0
  73. package/dist/lib/git-sync/whitelist/store.js +99 -0
  74. package/dist/lib/git-sync/whitelist/types.js +46 -0
  75. package/dist/lib/git-sync/whitelist/validation.js +60 -0
  76. package/dist/lib/graph/answer.js +171 -0
  77. package/dist/lib/graph/freshness.js +29 -0
  78. package/dist/lib/graph/library.js +19 -0
  79. package/dist/lib/guards.js +106 -0
  80. package/dist/lib/interactive.js +12 -0
  81. package/dist/lib/json_output.js +8 -0
  82. package/dist/lib/lanes/claudeAdapter.js +135 -0
  83. package/dist/lib/lanes/frames.js +73 -0
  84. package/dist/lib/lanes/supervisor.js +247 -0
  85. package/dist/lib/lanes/worktree.js +33 -0
  86. package/dist/lib/local_store/accepted_uploads.js +49 -0
  87. package/dist/lib/local_store/bindings.js +163 -0
  88. package/dist/lib/local_store/commit_origins.js +25 -0
  89. package/dist/lib/local_store/database.js +71 -0
  90. package/dist/lib/local_store/device_state.js +72 -0
  91. package/dist/lib/local_store/filebeat_state.js +133 -0
  92. package/dist/lib/local_store/handoff_applications.js +166 -0
  93. package/dist/lib/local_store/history_decisions.js +48 -0
  94. package/dist/lib/local_store/index.js +14 -0
  95. package/dist/lib/local_store/migrations.js +240 -0
  96. package/dist/lib/local_store/paths.js +24 -0
  97. package/dist/lib/local_store/pending_uploads.js +76 -0
  98. package/dist/lib/local_store/recovery_bundles.js +66 -0
  99. package/dist/lib/local_store/sync_vocabulary.js +33 -0
  100. package/dist/lib/local_store/workspace_events_cache.js +54 -0
  101. package/dist/lib/loopback.js +78 -0
  102. package/dist/lib/manifest.js +83 -0
  103. package/dist/lib/nustack_client.js +547 -0
  104. package/dist/lib/nustack_store.js +17 -0
  105. package/dist/lib/onboarding/attach_existing_git.js +65 -0
  106. package/dist/lib/onboarding/binding.js +91 -0
  107. package/dist/lib/onboarding/folder_state.js +99 -0
  108. package/dist/lib/onboarding/git_init.js +10 -0
  109. package/dist/lib/onboarding/handoff_apply.js +68 -0
  110. package/dist/lib/onboarding/init_fresh_folder.js +100 -0
  111. package/dist/lib/onboarding/manifest_writer.js +161 -0
  112. package/dist/lib/onboarding/web_first_attach.js +133 -0
  113. package/dist/lib/package_manager.js +23 -0
  114. package/dist/lib/review/client.js +66 -0
  115. package/dist/lib/schema_validator.js +24 -0
  116. package/dist/lib/secure_file.js +10 -0
  117. package/dist/lib/session_telemetry.js +263 -0
  118. package/dist/lib/sessions/client.js +48 -0
  119. package/dist/lib/sessions/outsideSessions.js +54 -0
  120. package/dist/lib/sse_framing.js +48 -0
  121. package/dist/lib/telemetry/config_renderer.js +175 -0
  122. package/dist/lib/telemetry/health_report.js +173 -0
  123. package/dist/lib/telemetry/provider_paths.js +38 -0
  124. package/dist/lib/telemetry/redaction_policy.js +90 -0
  125. package/dist/lib/telemetry/reload.js +84 -0
  126. package/dist/lib/workspace_context.js +121 -0
  127. package/dist/spec_schema.json +422 -0
  128. package/dist/wire/auth.js +1 -0
  129. package/dist/wire/sync-paths.js +25 -0
  130. package/dist/wire/telemetry.js +1 -0
  131. package/package.json +49 -0
@@ -0,0 +1,148 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, readFileSync, readlinkSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { runGit } from "../core/git_runner.js";
5
+ function sha256(bytes) {
6
+ return createHash("sha256").update(bytes).digest("hex");
7
+ }
8
+ function readHeadState(repo, targetPath) {
9
+ const result = runGit(repo.repositoryRoot, ["ls-tree", "HEAD", "--", targetPath], {
10
+ allowFailure: true,
11
+ });
12
+ const line = result.stdout.split("\n").find((entry) => entry.trim().length > 0);
13
+ if (result.exitCode !== 0 || !line)
14
+ return { absent: true };
15
+ const [meta] = line.split("\t");
16
+ const parts = (meta ?? "").split(/\s+/);
17
+ const mode = parts[0] ?? "";
18
+ const type = parts[1] ?? "";
19
+ const blobSha = parts[2] ?? "";
20
+ if (type !== "blob" || blobSha.length === 0)
21
+ return { absent: true };
22
+ const contentResult = runGit(repo.repositoryRoot, ["cat-file", "blob", blobSha], {
23
+ allowFailure: true,
24
+ });
25
+ const contentSha256 = sha256(Buffer.from(contentResult.stdout, "utf8"));
26
+ return { blobSha, mode, contentSha256 };
27
+ }
28
+ function readIndexEntries(repo, targetPath) {
29
+ const result = runGit(repo.repositoryRoot, ["ls-files", "--stage", "--", targetPath], {
30
+ allowFailure: true,
31
+ });
32
+ const entries = [];
33
+ for (const line of result.stdout.split("\n")) {
34
+ if (line.trim().length === 0)
35
+ continue;
36
+ const [meta] = line.split("\t");
37
+ const parts = (meta ?? "").split(/\s+/);
38
+ const mode = parts[0] ?? "";
39
+ const blobSha = parts[1] ?? "";
40
+ const stage = Number.parseInt(parts[2] ?? "0", 10);
41
+ if (blobSha.length > 0)
42
+ entries.push({ mode, blobSha, stage: Number.isFinite(stage) ? stage : 0 });
43
+ }
44
+ return entries;
45
+ }
46
+ function readWorkingState(repo, targetPath) {
47
+ const abs = path.join(repo.repositoryRoot, targetPath);
48
+ let stat;
49
+ try {
50
+ stat = lstatSync(abs);
51
+ }
52
+ catch {
53
+ return { absent: true };
54
+ }
55
+ if (stat.isSymbolicLink()) {
56
+ const link = readlinkSync(abs);
57
+ return { type: "symlink", contentSha256: sha256(Buffer.from(link, "utf8")) };
58
+ }
59
+ if (!stat.isFile())
60
+ return { absent: true };
61
+ const bytes = readFileSync(abs);
62
+ const blobResult = runGit(repo.repositoryRoot, ["hash-object", "--", targetPath], {
63
+ allowFailure: true,
64
+ });
65
+ return { type: "file", blobSha: blobResult.stdout.trim(), contentSha256: sha256(bytes) };
66
+ }
67
+ export function readTargetState(repo, targetPath) {
68
+ return {
69
+ path: targetPath,
70
+ head: readHeadState(repo, targetPath),
71
+ index: readIndexEntries(repo, targetPath),
72
+ working: readWorkingState(repo, targetPath),
73
+ };
74
+ }
75
+ function baseOf(target) {
76
+ return "sha256" in target.base ? { sha256: target.base.sha256 } : { absent: true };
77
+ }
78
+ function stageZeroBlob(index) {
79
+ const stageZero = index.filter((entry) => entry.stage === 0);
80
+ return stageZero.length === 1 ? stageZero[0].blobSha : null;
81
+ }
82
+ export function classifyTarget(state, target) {
83
+ const { head, working } = state;
84
+ const base = baseOf(target);
85
+ const decide = (classification, divergence) => ({ path: target.path, classification, ...(divergence ? { divergence } : {}) });
86
+ if (state.index.some((entry) => entry.stage !== 0) || state.index.length > 1) {
87
+ return decide("decision_required", "base_untrusted");
88
+ }
89
+ const headBlob = "absent" in head ? null : head.blobSha;
90
+ const headContentSha256 = "absent" in head ? null : head.contentSha256;
91
+ if (!("absent" in head) && !("absent" in working) && working.type === "symlink") {
92
+ return decide("decision_required", "type_changed");
93
+ }
94
+ const indexBlob = stageZeroBlob(state.index);
95
+ const indexClean = headBlob === null ? indexBlob === null : indexBlob === headBlob;
96
+ if (!indexClean)
97
+ return decide("decision_required", "staged_differently");
98
+ if ("absent" in head && !("absent" in working)) {
99
+ if ("absent" in base &&
100
+ target.operation === "upsert" &&
101
+ working.type === "file" &&
102
+ target.targetSha256 !== null &&
103
+ working.contentSha256 === target.targetSha256) {
104
+ return decide("already_equal");
105
+ }
106
+ return decide("decision_required", "untracked_at_path");
107
+ }
108
+ if (!("absent" in head) && "absent" in working) {
109
+ if (target.operation === "delete")
110
+ return decide("already_equal");
111
+ return decide("decision_required", "deleted_or_renamed");
112
+ }
113
+ if (!("absent" in head) && !("absent" in working)) {
114
+ const workingBlob = working.type === "file" ? working.blobSha : null;
115
+ if (workingBlob !== headBlob)
116
+ return decide("decision_required", "dirty");
117
+ }
118
+ if (target.operation === "delete") {
119
+ if ("absent" in head)
120
+ return decide("already_equal");
121
+ if ("sha256" in base && headContentSha256 === base.sha256)
122
+ return decide("automatic");
123
+ return decide("decision_required", "committed_away_from_base");
124
+ }
125
+ if (headContentSha256 !== null && target.targetSha256 !== null && headContentSha256 === target.targetSha256) {
126
+ return decide("already_equal");
127
+ }
128
+ if ("absent" in base) {
129
+ if ("absent" in head)
130
+ return decide("automatic");
131
+ return decide("decision_required", "committed_away_from_base");
132
+ }
133
+ if (headContentSha256 !== null && headContentSha256 === base.sha256)
134
+ return decide("automatic");
135
+ return decide("decision_required", "committed_away_from_base");
136
+ }
137
+ export function decideHandoffContent(repo, targets) {
138
+ const decisions = targets.map((target) => classifyTarget(readTargetState(repo, target.path), target));
139
+ const divergent = decisions
140
+ .filter((decision) => decision.classification === "decision_required")
141
+ .map((decision) => ({ path: decision.path, divergence: decision.divergence }));
142
+ if (divergent.length > 0)
143
+ return { kind: "decision_required", targets: decisions, divergent };
144
+ if (decisions.every((decision) => decision.classification === "already_equal")) {
145
+ return { kind: "no_op", targets: decisions };
146
+ }
147
+ return { kind: "automatic", targets: decisions };
148
+ }
@@ -0,0 +1,223 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import { readFile } from "node:fs/promises";
4
+ import { cacheDir } from "../../local_store/paths.js";
5
+ import { mkdirSecure, writeFileSecure } from "../../secure_file.js";
6
+ import { normalizeRepositoryPath, safeJoin, findCaseFoldCollisions, findUnicodeCollisions, } from "../core/path_normalizer.js";
7
+ import { fetchHandoffManifest, downloadTargetContent, fetchSyncWhitelist, } from "../../nustack_client.js";
8
+ const SYNC_PROTOCOL_VERSION = "nustack.dev/v2";
9
+ const reject = (code, detail) => ({ ok: false, kind: "rejected", code, detail });
10
+ const technical = (reason) => ({ ok: false, kind: "technical", reason });
11
+ function sha256(bytes) {
12
+ return createHash("sha256").update(bytes).digest("hex");
13
+ }
14
+ function sortKeys(value) {
15
+ if (Array.isArray(value))
16
+ return value.map(sortKeys);
17
+ if (value && typeof value === "object") {
18
+ const source = value;
19
+ const out = {};
20
+ for (const key of Object.keys(source).sort())
21
+ out[key] = sortKeys(source[key]);
22
+ return out;
23
+ }
24
+ return value;
25
+ }
26
+ function recomputeManifestHash(manifest) {
27
+ const core = {
28
+ schemaVersion: 1,
29
+ handoffId: manifest.handoffId,
30
+ workspaceId: manifest.workspaceId,
31
+ documentSetVersionId: manifest.documentSetVersionId,
32
+ toolkit: {
33
+ name: manifest.toolkit.name,
34
+ version: manifest.toolkit.version,
35
+ rendererVersion: manifest.toolkit.rendererVersion,
36
+ },
37
+ targets: manifest.targets.map((target) => ({
38
+ path: target.path,
39
+ operation: target.operation,
40
+ targetSha256: target.targetSha256,
41
+ byteSize: target.byteSize,
42
+ ...(target.baseAbsent ? { baseAbsent: true } : { baseSha256: target.baseSha256 }),
43
+ })),
44
+ };
45
+ return createHash("sha256").update(JSON.stringify(sortKeys(core))).digest("hex");
46
+ }
47
+ function toolkitTechnicalWait(binding, manifest) {
48
+ if (!binding.etnaProfileJson)
49
+ return null;
50
+ let profile;
51
+ try {
52
+ profile = JSON.parse(binding.etnaProfileJson);
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ if (profile.rendererVersion && profile.rendererVersion !== manifest.toolkit.rendererVersion)
58
+ return "renderer_incompatible";
59
+ if (profile.name && profile.name !== manifest.toolkit.name)
60
+ return "harness_incompatible";
61
+ if (profile.version && profile.version !== manifest.toolkit.version)
62
+ return "harness_incompatible";
63
+ return null;
64
+ }
65
+ function crossWorkspaceMarker(body, workspaceId) {
66
+ if (!body.startsWith("---\n"))
67
+ return false;
68
+ const secondLineEnd = body.indexOf("\n", 4);
69
+ const secondLine = secondLineEnd === -1 ? body.slice(4) : body.slice(4, secondLineEnd);
70
+ if (secondLine.trim() !== "nustack:")
71
+ return false;
72
+ const fence = body.indexOf("\n---", 4);
73
+ const block = body.slice(0, fence === -1 ? body.length : fence);
74
+ const match = /\n\s*workspace_id:\s*(\S+)/.exec(block);
75
+ return match !== null && match[1] !== workspaceId;
76
+ }
77
+ function isUnsupportedMedia(bytes) {
78
+ for (const byte of bytes)
79
+ if (byte === 0)
80
+ return true;
81
+ const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
82
+ const reencoded = new TextEncoder().encode(text);
83
+ if (reencoded.byteLength !== bytes.byteLength)
84
+ return true;
85
+ for (let i = 0; i < bytes.length; i++)
86
+ if (bytes[i] !== reencoded[i])
87
+ return true;
88
+ return false;
89
+ }
90
+ function eligibleEntry(targetPath, whitelist) {
91
+ for (const entry of whitelist.entries) {
92
+ if (entry.direction !== "bidirectional" && entry.direction !== "cloud_to_local")
93
+ continue;
94
+ if (entry.kind === "exact" ? entry.pattern === targetPath : globMatch(entry.pattern, targetPath)) {
95
+ return entry;
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+ function globMatch(pattern, target) {
101
+ return matchSegments(pattern.split("/"), 0, target.split("/"), 0);
102
+ }
103
+ function matchSegments(pattern, pi, target, ti) {
104
+ if (pi === pattern.length)
105
+ return ti === target.length;
106
+ if (pattern[pi] === "**") {
107
+ for (let k = ti; k <= target.length; k++)
108
+ if (matchSegments(pattern, pi + 1, target, k))
109
+ return true;
110
+ return false;
111
+ }
112
+ if (ti === target.length)
113
+ return false;
114
+ if (segmentMatch(pattern[pi], target[ti]))
115
+ return matchSegments(pattern, pi + 1, target, ti + 1);
116
+ return false;
117
+ }
118
+ function segmentMatch(pattern, segment) {
119
+ if (!pattern.includes("*"))
120
+ return pattern === segment;
121
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
122
+ return new RegExp(`^${escaped}$`).test(segment);
123
+ }
124
+ function blobCachePath(contentSha256) {
125
+ return path.join(cacheDir(), "blobs", contentSha256);
126
+ }
127
+ function normalizeBase(target) {
128
+ const hasSha = typeof target.baseSha256 === "string";
129
+ const hasAbsent = target.baseAbsent === true;
130
+ if (hasSha === hasAbsent)
131
+ return null;
132
+ return hasSha ? { sha256: target.baseSha256 } : { absent: true };
133
+ }
134
+ export async function downloadHandoff(deps, binding, handoffId) {
135
+ const fetchManifest = deps.fetchManifest ?? fetchHandoffManifest;
136
+ const downloadContent = deps.downloadContent ?? downloadTargetContent;
137
+ const fetchWhitelist = deps.fetchWhitelist ?? fetchSyncWhitelist;
138
+ const manifest = await fetchManifest(deps.baseUrl, deps.accessToken, { handoffId, bindingId: binding.id });
139
+ if (manifest.workspaceId !== binding.workspaceId)
140
+ return reject("workspace_mismatch", "manifest Workspace does not match the binding");
141
+ if (recomputeManifestHash(manifest) !== manifest.manifestSha256) {
142
+ return reject("manifest_hash", "manifest hash does not verify");
143
+ }
144
+ const toolkitWait = toolkitTechnicalWait(binding, manifest);
145
+ if (toolkitWait)
146
+ return technical(toolkitWait);
147
+ let whitelist = deps.cachedWhitelist ?? (await fetchWhitelist(deps.baseUrl, deps.accessToken, binding.id));
148
+ if (whitelist.protocolVersion !== SYNC_PROTOCOL_VERSION)
149
+ return technical("protocol_incompatible");
150
+ const normalizedTargets = [];
151
+ const seenPaths = new Set();
152
+ for (const target of manifest.targets) {
153
+ const normalized = normalizeRepositoryPath(target.path);
154
+ if (!normalized.ok) {
155
+ return normalized.code === "protected_path"
156
+ ? reject("protected_path", `target rejected: ${normalized.code}`)
157
+ : reject("invalid_path", `target rejected: ${normalized.code}`);
158
+ }
159
+ const canonical = normalized.path;
160
+ if (seenPaths.has(canonical))
161
+ return reject("operation_not_unique", "two operations target the same path");
162
+ seenPaths.add(canonical);
163
+ const base = normalizeBase(target);
164
+ if (base === null)
165
+ return reject("base_semantics", "target base is not exactly baseSha256 xor baseAbsent");
166
+ if (safeJoin(binding.repositoryRoot, canonical).ok === false) {
167
+ return reject("symlink_escape", "target path escapes the repository root or a symlink");
168
+ }
169
+ normalizedTargets.push({
170
+ path: canonical,
171
+ operation: target.operation,
172
+ targetSha256: target.targetSha256,
173
+ byteSize: target.byteSize,
174
+ base,
175
+ contentUrl: target.contentUrl,
176
+ });
177
+ }
178
+ const paths = normalizedTargets.map((target) => target.path);
179
+ if (findCaseFoldCollisions(paths).length > 0)
180
+ return reject("case_collision", "targets collide by case fold");
181
+ if (findUnicodeCollisions(paths).length > 0)
182
+ return reject("unicode_collision", "targets collide by Unicode normalization");
183
+ let refetched = false;
184
+ for (const target of normalizedTargets) {
185
+ let entry = eligibleEntry(target.path, whitelist);
186
+ if (!entry && !refetched) {
187
+ whitelist = await fetchWhitelist(deps.baseUrl, deps.accessToken, binding.id);
188
+ refetched = true;
189
+ entry = eligibleEntry(target.path, whitelist);
190
+ }
191
+ if (!entry)
192
+ return technical("whitelist_incompatible");
193
+ if (target.byteSize > entry.maxBytes)
194
+ return reject("byte_limit", "target exceeds the whitelist byte limit");
195
+ }
196
+ for (const target of normalizedTargets) {
197
+ if (target.operation === "delete")
198
+ continue;
199
+ if (!target.contentUrl || target.targetSha256 === null) {
200
+ return reject("missing_body", "upsert target has no content URL");
201
+ }
202
+ const bytes = await downloadContent(deps.baseUrl, deps.accessToken, target.contentUrl);
203
+ if (isUnsupportedMedia(bytes))
204
+ return reject("media_type", "target body is not valid UTF-8 text");
205
+ if (bytes.byteLength !== target.byteSize)
206
+ return reject("size_mismatch", "downloaded body size does not match the manifest");
207
+ const actualSha256 = sha256(bytes);
208
+ if (actualSha256 !== target.targetSha256)
209
+ return reject("hash_mismatch", "downloaded body hash does not match the manifest");
210
+ if (crossWorkspaceMarker(new TextDecoder().decode(bytes), binding.workspaceId)) {
211
+ return reject("cross_workspace_marker", "target body declares a foreign Workspace id");
212
+ }
213
+ const blobPath = blobCachePath(actualSha256);
214
+ await mkdirSecure(path.dirname(blobPath));
215
+ await writeFileSecure(blobPath, new TextDecoder().decode(bytes));
216
+ target.contentSha256 = actualSha256;
217
+ target.blobPath = blobPath;
218
+ }
219
+ return { ok: true, manifest, targets: normalizedTargets, whitelist };
220
+ }
221
+ export async function readCachedBlob(contentSha256) {
222
+ return readFile(blobCachePath(contentSha256));
223
+ }
@@ -0,0 +1,133 @@
1
+ import { getBindingById, updateBindingCursor } from "../../local_store/bindings.js";
2
+ import { hasCloudOriginForHandoff } from "../../local_store/commit_origins.js";
3
+ import { cacheWorkspaceEvents, listCachedWorkspaceEvents, pruneWorkspaceEvents, } from "../../local_store/workspace_events_cache.js";
4
+ import { getApplication, upsertApplication, transitionApplicationState, listApplicationsForBinding, } from "../../local_store/handoff_applications.js";
5
+ import { fetchBindingEvents, acknowledgeBindingCursor } from "../../nustack_client.js";
6
+ const PENDING_STATES = new Set([
7
+ "received",
8
+ "evaluating",
9
+ "technical_wait",
10
+ "decision_required",
11
+ "approved",
12
+ "applying",
13
+ ]);
14
+ const EVENTS_PAGE_LIMIT = 500;
15
+ function supersedeOlderApplications(store, bindingId, newerHandoffId) {
16
+ const flipped = [];
17
+ const pending = listApplicationsForBinding(store, bindingId, ["received", "evaluating", "decision_required"]);
18
+ for (const application of pending) {
19
+ if (application.handoffId === newerHandoffId)
20
+ continue;
21
+ upsertApplication(store, {
22
+ handoffId: application.handoffId,
23
+ bindingId,
24
+ supersededByHandoffId: newerHandoffId,
25
+ });
26
+ transitionApplicationState(store, application.handoffId, bindingId, "superseded");
27
+ flipped.push(application.handoffId);
28
+ }
29
+ return flipped;
30
+ }
31
+ function handoffIdOf(event) {
32
+ return event.type === "version.created" && event.entity.kind === "workspace_document_version" ? event.entity.id : null;
33
+ }
34
+ export async function syncBindingEvents(store, deps, bindingId) {
35
+ const binding = getBindingById(store, bindingId);
36
+ if (!binding)
37
+ throw new Error(`No binding "${bindingId}" on this machine.`);
38
+ const fetchEvents = deps.fetchEvents ?? fetchBindingEvents;
39
+ const result = {
40
+ createdHandoffs: [],
41
+ supersededHandoffs: [],
42
+ acknowledgedOwnOrigin: [],
43
+ highestSequence: binding.lastAppliedWorkspaceSequence,
44
+ };
45
+ let after = binding.lastAppliedWorkspaceSequence;
46
+ for (;;) {
47
+ const events = await fetchEvents(deps.baseUrl, deps.accessToken, {
48
+ bindingId,
49
+ after,
50
+ limit: EVENTS_PAGE_LIMIT,
51
+ });
52
+ if (events.length === 0)
53
+ break;
54
+ cacheWorkspaceEvents(store, bindingId, events.map((event) => ({
55
+ workspaceSequence: event.sequence,
56
+ eventType: event.type,
57
+ payloadJson: JSON.stringify({ entity: event.entity, payload: event.payload }),
58
+ })));
59
+ const ordered = [...events].sort((a, b) => a.sequence - b.sequence);
60
+ for (const event of ordered) {
61
+ const handoffId = handoffIdOf(event);
62
+ if (handoffId === null)
63
+ continue;
64
+ result.supersededHandoffs.push(...supersedeOlderApplications(store, bindingId, handoffId));
65
+ const existing = getApplication(store, handoffId, bindingId);
66
+ if (existing)
67
+ continue;
68
+ upsertApplication(store, { handoffId, bindingId });
69
+ if (hasCloudOriginForHandoff(store, handoffId)) {
70
+ transitionApplicationState(store, handoffId, bindingId, "evaluating");
71
+ transitionApplicationState(store, handoffId, bindingId, "no_op");
72
+ result.acknowledgedOwnOrigin.push(handoffId);
73
+ }
74
+ else {
75
+ result.createdHandoffs.push(handoffId);
76
+ }
77
+ }
78
+ const highest = ordered[ordered.length - 1].sequence;
79
+ result.highestSequence = Math.max(result.highestSequence, highest);
80
+ after = highest;
81
+ if (events.length < EVENTS_PAGE_LIMIT)
82
+ break;
83
+ }
84
+ pruneWorkspaceEvents(store, bindingId);
85
+ return result;
86
+ }
87
+ function cachedHandoffId(eventType, payloadJson) {
88
+ if (eventType !== "version.created" || !payloadJson)
89
+ return null;
90
+ try {
91
+ const parsed = JSON.parse(payloadJson);
92
+ return typeof parsed.entity?.id === "string" ? parsed.entity.id : null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ export async function advanceContiguousCursor(store, deps, bindingId) {
99
+ const binding = getBindingById(store, bindingId);
100
+ if (!binding)
101
+ throw new Error(`No binding "${bindingId}" on this machine.`);
102
+ const acknowledgeCursor = deps.acknowledgeCursor ?? acknowledgeBindingCursor;
103
+ let cursor = binding.lastAppliedWorkspaceSequence;
104
+ for (const event of listCachedWorkspaceEvents(store, bindingId)) {
105
+ if (event.workspaceSequence <= cursor)
106
+ continue;
107
+ const handoffId = cachedHandoffId(event.eventType, event.payloadJson);
108
+ if (handoffId !== null) {
109
+ const application = getApplication(store, handoffId, bindingId);
110
+ if (!application || PENDING_STATES.has(application.state))
111
+ break;
112
+ }
113
+ cursor = event.workspaceSequence;
114
+ }
115
+ if (cursor > binding.lastAppliedWorkspaceSequence) {
116
+ updateBindingCursor(store, bindingId, { lastAppliedWorkspaceSequence: cursor });
117
+ try {
118
+ await acknowledgeCursor(deps.baseUrl, deps.accessToken, { bindingId, lastAppliedSequence: cursor });
119
+ }
120
+ catch {
121
+ }
122
+ }
123
+ return cursor;
124
+ }
125
+ export function pendingApplications(store, bindingId) {
126
+ return listApplicationsForBinding(store, bindingId, [
127
+ "received",
128
+ "evaluating",
129
+ "technical_wait",
130
+ "decision_required",
131
+ "approved",
132
+ ]);
133
+ }
@@ -0,0 +1,10 @@
1
+ export { runInboundReconciliation, reconcileInboundBinding, createInboundApplier, registerInboundApplier, retryApplication, } from "./applier.js";
2
+ export { approveHandoff, serializeDecisionEvidence, toDownloadBinding, APPROVAL_RECOVERY_NOTE, } from "./approval.js";
3
+ export { undoHandoff } from "./undo.js";
4
+ export { COMMIT_NOTIFICATION_COPY, renderCommitNotification, listUnacknowledgedNotifications, acknowledgeNotification, postResultReport, } from "./notification.js";
5
+ export { syncBindingEvents, advanceContiguousCursor, pendingApplications, } from "./event_sync.js";
6
+ export { downloadHandoff, readCachedBlob, } from "./download.js";
7
+ export { decideHandoffContent, classifyTarget, readTargetState, } from "./content_decision.js";
8
+ export { computeStateFingerprint } from "./state_fingerprint.js";
9
+ export { evaluateRepositoryTechnicalWait, } from "./technical_wait.js";
10
+ export { applyCommit, finalizeCommitted, buildCommitMessage, NUSTACK_SYNC_IDENTITY, } from "./apply_commit.js";
@@ -0,0 +1,39 @@
1
+ import { listApplicationsForBinding, upsertApplication, } from "../../local_store/handoff_applications.js";
2
+ import { reportApplicationResult } from "../../nustack_client.js";
3
+ export const COMMIT_NOTIFICATION_COPY = "A new NuStack sync change arrived and was committed on your behalf. NuStack did not push it.";
4
+ export function renderCommitNotification(application, changedPaths) {
5
+ const lines = [COMMIT_NOTIFICATION_COPY];
6
+ if (application.generatedCommitId)
7
+ lines.push(` commit: ${application.generatedCommitId.slice(0, 12)}`);
8
+ if (changedPaths.length > 0)
9
+ lines.push(` changed: ${changedPaths.join(", ")}`);
10
+ lines.push(` View: nustack workspace sync pending Undo: nustack workspace sync undo ${application.handoffId}`);
11
+ if (application.recoveryBundleId) {
12
+ lines.push(` Recovery bundle ${application.recoveryBundleId} holds your pre-approval content until it expires.`);
13
+ }
14
+ return lines;
15
+ }
16
+ export function listUnacknowledgedNotifications(store, bindingId) {
17
+ return listApplicationsForBinding(store, bindingId, ["committed", "reverted", "revert_conflict"]).filter((application) => application.notificationState === "unacknowledged");
18
+ }
19
+ export function acknowledgeNotification(store, handoffId, bindingId) {
20
+ upsertApplication(store, { handoffId, bindingId, notificationState: "acknowledged" });
21
+ }
22
+ export async function postResultReport(deps, binding, input) {
23
+ const reportResult = deps.reportResult ?? reportApplicationResult;
24
+ try {
25
+ await reportResult(deps.baseUrl, deps.accessToken, {
26
+ bindingId: binding.id,
27
+ handoffId: input.handoffId,
28
+ idempotencyKey: `result:${binding.id}:${input.handoffId}`,
29
+ commitId: input.commitId,
30
+ status: input.status,
31
+ manifestHash: input.manifestHash,
32
+ affectedPaths: input.affectedPaths,
33
+ });
34
+ return { delivered: true };
35
+ }
36
+ catch {
37
+ return { delivered: false };
38
+ }
39
+ }
@@ -0,0 +1,32 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readHeadCommit } from "../core/repository.js";
3
+ import { readGitOperationState } from "../core/operation_state.js";
4
+ import { readTargetState } from "./content_decision.js";
5
+ function projectTargetState(state) {
6
+ return {
7
+ path: state.path,
8
+ head: "absent" in state.head
9
+ ? { absent: true }
10
+ : { blobSha: state.head.blobSha, mode: state.head.mode, contentSha256: state.head.contentSha256 },
11
+ index: [...state.index]
12
+ .sort((a, b) => (a.stage === b.stage ? a.blobSha.localeCompare(b.blobSha) : a.stage - b.stage))
13
+ .map((entry) => ({ mode: entry.mode, blobSha: entry.blobSha, stage: entry.stage })),
14
+ working: "absent" in state.working
15
+ ? { absent: true }
16
+ : state.working.type === "symlink"
17
+ ? { type: "symlink", contentSha256: state.working.contentSha256 }
18
+ : { type: "file", blobSha: state.working.blobSha, contentSha256: state.working.contentSha256 },
19
+ };
20
+ }
21
+ export function computeStateFingerprint(repo, targetPaths, manifestSha256) {
22
+ const payload = {
23
+ head: readHeadCommit(repo) ?? null,
24
+ operationState: readGitOperationState(repo).kind,
25
+ manifestSha256,
26
+ targets: [...targetPaths]
27
+ .sort()
28
+ .map((targetPath) => projectTargetState(readTargetState(repo, targetPath))),
29
+ };
30
+ const hex = createHash("sha256").update(JSON.stringify(payload)).digest("hex");
31
+ return `sha256:${hex}`;
32
+ }
@@ -0,0 +1,39 @@
1
+ import { readSymbolicBranch } from "../core/repository.js";
2
+ import { readGitOperationState } from "../core/operation_state.js";
3
+ function operationReason(kind) {
4
+ switch (kind) {
5
+ case "merge":
6
+ return "merge_in_progress";
7
+ case "rebase":
8
+ return "rebase_in_progress";
9
+ case "cherry_pick":
10
+ return "cherry_pick_in_progress";
11
+ case "revert":
12
+ return "revert_in_progress";
13
+ case "bisect":
14
+ return "bisect_in_progress";
15
+ case "index_locked":
16
+ return "index_locked";
17
+ case "normal":
18
+ return null;
19
+ }
20
+ }
21
+ export function evaluateRepositoryTechnicalWait(repo, boundBranch) {
22
+ let operationKind;
23
+ let branch;
24
+ try {
25
+ operationKind = readGitOperationState(repo).kind;
26
+ branch = readSymbolicBranch(repo);
27
+ }
28
+ catch {
29
+ return { wait: true, reason: "repository_integrity" };
30
+ }
31
+ const opReason = operationReason(operationKind);
32
+ if (opReason)
33
+ return { wait: true, reason: opReason };
34
+ if (branch === null)
35
+ return { wait: true, reason: "detached_head" };
36
+ if (branch !== boundBranch)
37
+ return { wait: true, reason: "other_branch_checked_out" };
38
+ return { wait: false };
39
+ }