@treeseed/dsh-chapters 0.1.0
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/LICENSE +14 -0
- package/README.md +644 -0
- package/cordis.patch.yml +88 -0
- package/docs/architecture.md +460 -0
- package/docs/contract.md +375 -0
- package/docs/development.md +437 -0
- package/docs/host-compaction-seam.md +117 -0
- package/docs/knowledge-repo.md +730 -0
- package/docs/provider.md +98 -0
- package/docs/verify.md +200 -0
- package/lib/archive.d.ts +88 -0
- package/lib/archive.js +114 -0
- package/lib/archive.js.map +1 -0
- package/lib/arrival.d.ts +23 -0
- package/lib/arrival.js +86 -0
- package/lib/arrival.js.map +1 -0
- package/lib/artifact-query.d.ts +53 -0
- package/lib/artifact-query.js +130 -0
- package/lib/artifact-query.js.map +1 -0
- package/lib/client.js +234 -0
- package/lib/client.js.map +1 -0
- package/lib/commands.d.ts +28 -0
- package/lib/commands.js +305 -0
- package/lib/commands.js.map +1 -0
- package/lib/compose.d.ts +30 -0
- package/lib/compose.js +131 -0
- package/lib/compose.js.map +1 -0
- package/lib/continue-core.d.ts +153 -0
- package/lib/continue-core.js +252 -0
- package/lib/continue-core.js.map +1 -0
- package/lib/engine-core.d.ts +219 -0
- package/lib/engine-core.js +405 -0
- package/lib/engine-core.js.map +1 -0
- package/lib/engine.d.ts +211 -0
- package/lib/engine.js +571 -0
- package/lib/engine.js.map +1 -0
- package/lib/enrich-queue.d.ts +66 -0
- package/lib/enrich-queue.js +100 -0
- package/lib/enrich-queue.js.map +1 -0
- package/lib/enrich-store.d.ts +31 -0
- package/lib/enrich-store.js +103 -0
- package/lib/enrich-store.js.map +1 -0
- package/lib/enrich-wire.d.ts +73 -0
- package/lib/enrich-wire.js +209 -0
- package/lib/enrich-wire.js.map +1 -0
- package/lib/enrich.d.ts +73 -0
- package/lib/enrich.js +150 -0
- package/lib/enrich.js.map +1 -0
- package/lib/gitops.d.ts +107 -0
- package/lib/gitops.js +539 -0
- package/lib/gitops.js.map +1 -0
- package/lib/index.d.ts +81 -0
- package/lib/index.js +305 -0
- package/lib/index.js.map +1 -0
- package/lib/indexing.d.ts +85 -0
- package/lib/indexing.js +212 -0
- package/lib/indexing.js.map +1 -0
- package/lib/injections.d.ts +66 -0
- package/lib/injections.js +83 -0
- package/lib/injections.js.map +1 -0
- package/lib/notice.d.ts +60 -0
- package/lib/notice.js +84 -0
- package/lib/notice.js.map +1 -0
- package/lib/provider.d.ts +44 -0
- package/lib/provider.js +66 -0
- package/lib/provider.js.map +1 -0
- package/lib/redact.d.ts +24 -0
- package/lib/redact.js +85 -0
- package/lib/redact.js.map +1 -0
- package/lib/registry.d.ts +120 -0
- package/lib/registry.js +130 -0
- package/lib/registry.js.map +1 -0
- package/lib/render.d.ts +40 -0
- package/lib/render.js +280 -0
- package/lib/render.js.map +1 -0
- package/lib/repo.d.ts +78 -0
- package/lib/repo.js +184 -0
- package/lib/repo.js.map +1 -0
- package/lib/rules-commands.d.ts +24 -0
- package/lib/rules-commands.js +138 -0
- package/lib/rules-commands.js.map +1 -0
- package/lib/rules.d.ts +90 -0
- package/lib/rules.js +185 -0
- package/lib/rules.js.map +1 -0
- package/lib/search.d.ts +48 -0
- package/lib/search.js +152 -0
- package/lib/search.js.map +1 -0
- package/lib/signature.d.ts +28 -0
- package/lib/signature.js +149 -0
- package/lib/signature.js.map +1 -0
- package/lib/store.d.ts +130 -0
- package/lib/store.js +235 -0
- package/lib/store.js.map +1 -0
- package/lib/sync.d.ts +183 -0
- package/lib/sync.js +719 -0
- package/lib/sync.js.map +1 -0
- package/lib/tools.d.ts +84 -0
- package/lib/tools.js +581 -0
- package/lib/tools.js.map +1 -0
- package/lib/treedx/client.d.ts +57 -0
- package/lib/treedx/client.js +82 -0
- package/lib/treedx/client.js.map +1 -0
- package/lib/treedx/provider.d.ts +39 -0
- package/lib/treedx/provider.js +479 -0
- package/lib/treedx/provider.js.map +1 -0
- package/lib/treedx/state.d.ts +27 -0
- package/lib/treedx/state.js +65 -0
- package/lib/treedx/state.js.map +1 -0
- package/lib/types.d.ts +100 -0
- package/lib/types.js +19 -0
- package/lib/types.js.map +1 -0
- package/lib/vocabulary.d.ts +41 -0
- package/lib/vocabulary.js +118 -0
- package/lib/vocabulary.js.map +1 -0
- package/package.json +132 -0
- package/presets/chapters/agent.cordis.yml +272 -0
- package/presets/chapters/preset.yml +3 -0
- package/presets/probe-roots/agent.cordis.yml +11 -0
- package/presets/probe-roots/preset.yml +3 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TreeDX HTTP client (provider transport). Thin on purpose: one request
|
|
3
|
+
* shape, bearer auth, bounded timeout, and the sync loop's `{ok, detail, code}`
|
|
4
|
+
* error idiom (§5.3 — transport failures are VALUES, never exceptions).
|
|
5
|
+
*
|
|
6
|
+
* Every response shape consumed here is [doc]-pinned in
|
|
7
|
+
* spikes/treedx/FINDINGS.md, contract-pinned by the stub server, and re-checked
|
|
8
|
+
* live by tests/integration/treedx-live.test.ts. Do not add "convenience"
|
|
9
|
+
* retries here — retry policy is the sync loop's (pull-and-retry, diverged-
|
|
10
|
+
* rebuild) and belongs to the provider verbs.
|
|
11
|
+
*
|
|
12
|
+
* Credential hygiene (record §2.1): the token appears in exactly one place —
|
|
13
|
+
* the request header. No detail/status string is ever built from a request or
|
|
14
|
+
* response that could carry it (TreeDX error envelopes are {code,message,
|
|
15
|
+
* details}; we surface code + message only).
|
|
16
|
+
*/
|
|
17
|
+
export interface FetchLike {
|
|
18
|
+
(url: string, init: Record<string, unknown>): Promise<{
|
|
19
|
+
status: number;
|
|
20
|
+
ok: boolean;
|
|
21
|
+
text(): Promise<string>;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
export interface TreeDxOk {
|
|
25
|
+
ok: true;
|
|
26
|
+
status: number;
|
|
27
|
+
data: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
export interface TreeDxFail {
|
|
30
|
+
ok: false;
|
|
31
|
+
status: number;
|
|
32
|
+
/** TreeDX machine code when the envelope carried one (e.g. 'conflict',
|
|
33
|
+
* 'authentication_required', 'not_found'); 'network' / 'timeout' / 'bad_json'
|
|
34
|
+
* are ours. The provider branches on THESE, never on prose (r35 lesson). */
|
|
35
|
+
code: string;
|
|
36
|
+
detail: string;
|
|
37
|
+
}
|
|
38
|
+
export type TreeDxResult<T = Record<string, unknown>> = (TreeDxOk & {
|
|
39
|
+
data: T;
|
|
40
|
+
}) | TreeDxFail;
|
|
41
|
+
export interface TreeDxClientOpts {
|
|
42
|
+
baseUrl: string;
|
|
43
|
+
token?: string;
|
|
44
|
+
/** fetch impl timeout */
|
|
45
|
+
fetchTimeoutMs?: number;
|
|
46
|
+
fetchImpl?: FetchLike;
|
|
47
|
+
}
|
|
48
|
+
export interface TreeDxClient {
|
|
49
|
+
get(path: string): Promise<TreeDxResult>;
|
|
50
|
+
post(path: string, body?: unknown): Promise<TreeDxResult>;
|
|
51
|
+
put(path: string, body?: unknown): Promise<TreeDxResult>;
|
|
52
|
+
del(path: string): Promise<TreeDxResult>;
|
|
53
|
+
}
|
|
54
|
+
/** Unwrap TreeDX's document envelopes: some routes nest under a named key.
|
|
55
|
+
* The provider passes the candidate keys; first present object wins. */
|
|
56
|
+
export declare function pick(data: Record<string, unknown>, ...keys: string[]): Record<string, unknown> | undefined;
|
|
57
|
+
export declare function createTreeDxClient(opts: TreeDxClientOpts): TreeDxClient;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TreeDX HTTP client (provider transport). Thin on purpose: one request
|
|
3
|
+
* shape, bearer auth, bounded timeout, and the sync loop's `{ok, detail, code}`
|
|
4
|
+
* error idiom (§5.3 — transport failures are VALUES, never exceptions).
|
|
5
|
+
*
|
|
6
|
+
* Every response shape consumed here is [doc]-pinned in
|
|
7
|
+
* spikes/treedx/FINDINGS.md, contract-pinned by the stub server, and re-checked
|
|
8
|
+
* live by tests/integration/treedx-live.test.ts. Do not add "convenience"
|
|
9
|
+
* retries here — retry policy is the sync loop's (pull-and-retry, diverged-
|
|
10
|
+
* rebuild) and belongs to the provider verbs.
|
|
11
|
+
*
|
|
12
|
+
* Credential hygiene (record §2.1): the token appears in exactly one place —
|
|
13
|
+
* the request header. No detail/status string is ever built from a request or
|
|
14
|
+
* response that could carry it (TreeDX error envelopes are {code,message,
|
|
15
|
+
* details}; we surface code + message only).
|
|
16
|
+
*/
|
|
17
|
+
/** Unwrap TreeDX's document envelopes: some routes nest under a named key.
|
|
18
|
+
* The provider passes the candidate keys; first present object wins. */
|
|
19
|
+
export function pick(data, ...keys) {
|
|
20
|
+
for (const k of keys) {
|
|
21
|
+
const v = data[k];
|
|
22
|
+
if (v !== null && typeof v === 'object')
|
|
23
|
+
return v;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
export function createTreeDxClient(opts) {
|
|
28
|
+
const baseUrl = opts.baseUrl.replace(/\/+$/, '');
|
|
29
|
+
const timeoutMs = opts.fetchTimeoutMs ?? 15000;
|
|
30
|
+
const doFetch = opts.fetchImpl ?? ((u, init) => fetch(u, init).then((r) => ({
|
|
31
|
+
status: r.status,
|
|
32
|
+
ok: r.ok,
|
|
33
|
+
text: () => r.text(),
|
|
34
|
+
})));
|
|
35
|
+
const req = async (method, path, body) => {
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
38
|
+
try {
|
|
39
|
+
const headers = { 'content-type': 'application/json' };
|
|
40
|
+
if (opts.token !== undefined && opts.token !== '')
|
|
41
|
+
headers.authorization = `Bearer ${opts.token}`;
|
|
42
|
+
const res = await doFetch(`${baseUrl}${path}`, {
|
|
43
|
+
method,
|
|
44
|
+
headers,
|
|
45
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
});
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
let data = {};
|
|
50
|
+
if (text !== '') {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(text);
|
|
53
|
+
data = parsed !== null && typeof parsed === 'object' ? parsed : { value: parsed };
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
if (res.ok)
|
|
57
|
+
return { ok: false, status: res.status, code: 'bad_json', detail: `${method} ${path}: non-JSON response (${text.slice(0, 120)})` };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (res.ok)
|
|
61
|
+
return { ok: true, status: res.status, data };
|
|
62
|
+
const err = (data.error ?? {});
|
|
63
|
+
const code = String(err.code ?? `http_${res.status}`);
|
|
64
|
+
const message = String(err.message ?? `${method} ${path} failed`);
|
|
65
|
+
return { ok: false, status: res.status, code, detail: `${code} (${res.status}): ${message}`.slice(0, 300) };
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const aborted = error?.name === 'AbortError';
|
|
69
|
+
return { ok: false, status: 0, code: aborted ? 'timeout' : 'network', detail: `${method} ${path}: ${aborted ? `timed out after ${timeoutMs}ms` : String(error?.message ?? error)}`.slice(0, 300) };
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
get: (p) => req('GET', p),
|
|
77
|
+
post: (p, b) => req('POST', p, b ?? {}),
|
|
78
|
+
put: (p, b) => req('PUT', p, b ?? {}),
|
|
79
|
+
del: (p) => req('DELETE', p),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/treedx/client.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type SyncProvider } from '../provider.ts';
|
|
2
|
+
import { type TreeDxClient, type FetchLike } from './client.ts';
|
|
3
|
+
export interface TreedxProviderConfig {
|
|
4
|
+
fetchTimeoutMs: number;
|
|
5
|
+
workspaceTtlSeconds: number;
|
|
6
|
+
leaseRetries: number;
|
|
7
|
+
leaseRetryDelayMs: number;
|
|
8
|
+
/** Injectable (stub tests and any future transport instrumentation). */
|
|
9
|
+
fetchImpl?: FetchLike;
|
|
10
|
+
/** Injectable sleep (tests run the retry path without wall-clock cost). */
|
|
11
|
+
sleep?: (ms: number) => Promise<void>;
|
|
12
|
+
now?: () => number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The live-shaped repository catalog: GET /repos → {ok, repos:[…]} (older
|
|
16
|
+
* docs called it `repositories`); rows carry `repoId` (live-measured) or a
|
|
17
|
+
* bare `id`. One parser for the provider AND the /chapters-link command —
|
|
18
|
+
* this exact shape drift is why the first live run broke.
|
|
19
|
+
*/
|
|
20
|
+
export declare function repoCatalog(data: Record<string, unknown>): Array<{
|
|
21
|
+
name: string;
|
|
22
|
+
repoId: string;
|
|
23
|
+
}>;
|
|
24
|
+
/** Resolve a repository's catalog id by name ('' = not found). */
|
|
25
|
+
export declare function resolveRepoId(client: TreeDxClient, name: string): Promise<string>;
|
|
26
|
+
/**
|
|
27
|
+
* Create the provider (registered under kind 'treedx'). One instance per
|
|
28
|
+
* plane (host/engine); the mirror's state file carries everything across
|
|
29
|
+
* calls, so instances remain safe to share across cwds/projects.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createTreedxProvider(cfgIn?: Partial<TreedxProviderConfig>): SyncProvider & {
|
|
32
|
+
ensureClone: SyncProvider['ensureClone'];
|
|
33
|
+
};
|
|
34
|
+
/** The default instance registered at module load with production defaults;
|
|
35
|
+
* both planes re-register with user config at boot (src/index.ts, engine). */
|
|
36
|
+
declare const defaultProvider: SyncProvider & {
|
|
37
|
+
ensureClone: SyncProvider["ensureClone"];
|
|
38
|
+
};
|
|
39
|
+
export { defaultProvider as treedxProvider };
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TreeDX transport provider (src/provider.ts seam; design pinned in
|
|
3
|
+
* spikes/treedx/FINDINGS.md). It implements the sync loop's six transport
|
|
4
|
+
* verbs over TreeDX's no-clone HTTP API, mapping them so runSync's proven
|
|
5
|
+
* control flow — publish → commit → pull → push, degraded to local-only on
|
|
6
|
+
* any remote failure, rebuilt from the store on divergence — keeps working
|
|
7
|
+
* UNCHANGED. The mirror directory is still materialized (§3.1: it is the
|
|
8
|
+
* search/rules/index read surface in every mode); only what fills it changes.
|
|
9
|
+
*
|
|
10
|
+
* Verb mapping (the six, in runSync order):
|
|
11
|
+
* - ensureClone resolve the managed repo, fetch head + corpus into the
|
|
12
|
+
* mirror, write the state file (origin, head, baseline).
|
|
13
|
+
* Origin changed ⇒ 'origin-mismatch' (same code, same
|
|
14
|
+
* rebuild behavior as git mode).
|
|
15
|
+
* - stageAllAndCommit LOCAL workdir-vs-baseline diff, recorded as `staged`
|
|
16
|
+
* (mirrors git's local-commit step: no network here).
|
|
17
|
+
* - pullFastForward head compare vs state. Moved while we hold staged
|
|
18
|
+
* work ⇒ 'diverged' (the rebuild path re-publishes from
|
|
19
|
+
* the store and re-derives the index — safer than an
|
|
20
|
+
* ff refetch could ever be: derived files must not go
|
|
21
|
+
* stale against a corpus they predate). Moved with
|
|
22
|
+
* nothing staged ⇒ plain refetch (ff).
|
|
23
|
+
* - push workspace-create (one writable lease per branch) →
|
|
24
|
+
* overlay-write each staged path (UTF-8 file API; binary
|
|
25
|
+
* -safe blob API) → commit. Lease contention ⇒ 'rejected'
|
|
26
|
+
* (runSync's existing pull-and-retry, plus bounded
|
|
27
|
+
* in-provider waits); moved-base commit ⇒ 'diverged'.
|
|
28
|
+
* - initLocal offline state file (publish/commit/index/search keep
|
|
29
|
+
* working; push deferred — §5.3).
|
|
30
|
+
* - removeMirror inherited from the filesystem reality (mirror is
|
|
31
|
+
* transport, the store is truth).
|
|
32
|
+
*
|
|
33
|
+
* Every failure is a VALUE ({ok:false, code}) — never a throw into the
|
|
34
|
+
* conversation path, and never a prose-matched classification (r35).
|
|
35
|
+
*/
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { parseKnowledgeRemote } from "../repo.js";
|
|
39
|
+
import { registerProvider } from "../provider.js";
|
|
40
|
+
import { createTreeDxClient, pick } from "./client.js";
|
|
41
|
+
import { contentHash, isUtf8Safe, readState, walkMirror, writeState } from "./state.js";
|
|
42
|
+
const DEFAULTS = {
|
|
43
|
+
fetchTimeoutMs: 15000,
|
|
44
|
+
workspaceTtlSeconds: 900,
|
|
45
|
+
leaseRetries: 3,
|
|
46
|
+
leaseRetryDelayMs: 1000,
|
|
47
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
48
|
+
now: () => Date.now(),
|
|
49
|
+
};
|
|
50
|
+
const REF = 'refs/heads/main';
|
|
51
|
+
const sleepOf = (cfg) => cfg.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
52
|
+
/**
|
|
53
|
+
* The live-shaped repository catalog: GET /repos → {ok, repos:[…]} (older
|
|
54
|
+
* docs called it `repositories`); rows carry `repoId` (live-measured) or a
|
|
55
|
+
* bare `id`. One parser for the provider AND the /chapters-link command —
|
|
56
|
+
* this exact shape drift is why the first live run broke.
|
|
57
|
+
*/
|
|
58
|
+
export function repoCatalog(data) {
|
|
59
|
+
const rows = Array.isArray(data.repos) ? data.repos
|
|
60
|
+
: Array.isArray(data.repositories) ? data.repositories : [];
|
|
61
|
+
return rows.map((x) => ({
|
|
62
|
+
name: String(x.repositoryName ?? x.name ?? ''),
|
|
63
|
+
repoId: String(x.repoId ?? x.id ?? ''),
|
|
64
|
+
})).filter((x) => x.repoId !== '');
|
|
65
|
+
}
|
|
66
|
+
/** Resolve a repository's catalog id by name ('' = not found). */
|
|
67
|
+
export async function resolveRepoId(client, name) {
|
|
68
|
+
const list = await client.get('/api/v1/repos');
|
|
69
|
+
if (!list.ok)
|
|
70
|
+
return '';
|
|
71
|
+
return repoCatalog(list.data).find((x) => x.name === name)?.repoId ?? '';
|
|
72
|
+
}
|
|
73
|
+
async function resolve(cfg, remote, state) {
|
|
74
|
+
let target;
|
|
75
|
+
try {
|
|
76
|
+
target = parseKnowledgeRemote(remote.url);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return { ok: false, res: { ok: false, detail: `bad treedx remote: ${String(error?.message ?? error)}` } };
|
|
80
|
+
}
|
|
81
|
+
if (target.treedx === undefined) {
|
|
82
|
+
return { ok: false, res: { ok: false, detail: `remote ${remote.url} is not a treedx+ target` } };
|
|
83
|
+
}
|
|
84
|
+
const client = createTreeDxClient({
|
|
85
|
+
baseUrl: target.treedx.baseUrl,
|
|
86
|
+
...(remote.token !== undefined ? { token: remote.token } : {}),
|
|
87
|
+
fetchTimeoutMs: cfg.fetchTimeoutMs,
|
|
88
|
+
...(cfg.fetchImpl !== undefined ? { fetchImpl: cfg.fetchImpl } : {}),
|
|
89
|
+
});
|
|
90
|
+
// Repo id: prefer the state-cached id (stable across passes); otherwise
|
|
91
|
+
// resolve by canonical name through the catalog.
|
|
92
|
+
const list = await client.get('/api/v1/repos');
|
|
93
|
+
if (!list.ok) {
|
|
94
|
+
if (list.code === 'authentication_required' || list.code === 'invalid_token' || list.status === 401 || list.status === 403) {
|
|
95
|
+
return { ok: false, res: { ok: false, code: 'auth', detail: `TreeDX auth rejected (${list.detail}) — re-link the token with /chapters-link` } };
|
|
96
|
+
}
|
|
97
|
+
return { ok: false, res: { ok: false, code: 'network', detail: `repos list: ${list.detail}` } };
|
|
98
|
+
}
|
|
99
|
+
const catalog = repoCatalog(list.data);
|
|
100
|
+
const want = target.treedx.repoName;
|
|
101
|
+
const found = catalog.find((x) => x.name === want);
|
|
102
|
+
const repoId = found !== undefined ? found.repoId : '';
|
|
103
|
+
if (repoId === '' && state?.repoId === undefined) {
|
|
104
|
+
return { ok: false, res: { ok: false, code: 'not_found', detail: `TreeDX has no repository '${want}' at ${target.treedx.baseUrl} (create it or re-link)` } };
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, r: { target, client, repoId: repoId !== '' ? repoId : String(state?.repoId ?? '') } };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Head sha of `refs/heads/main`. LIVE-MEASURED (spikes/treedx/FINDINGS.md):
|
|
110
|
+
* refs entries carry `{kind:'branch', name, target}` — the sha key is
|
|
111
|
+
* `target`; and a managed repository is BORN with `refs/heads/main` (a
|
|
112
|
+
* seeding commit holding `.treedxkeep`), so `null` here only means an
|
|
113
|
+
* unexpected listing failure shape, handled tolerantly. Repo routes address
|
|
114
|
+
* by repoId (name 404s).
|
|
115
|
+
*/
|
|
116
|
+
async function headOf(client, repoId) {
|
|
117
|
+
const refs = await client.get(`/api/v1/repos/${encodeURIComponent(repoId)}/refs`);
|
|
118
|
+
if (!refs.ok) {
|
|
119
|
+
if (refs.code === 'not_found')
|
|
120
|
+
return { ok: true, head: null };
|
|
121
|
+
return { ok: false, res: { ok: false, code: refs.code === 'auth' ? 'auth' : 'network', detail: `refs: ${refs.detail}` } };
|
|
122
|
+
}
|
|
123
|
+
const list = Array.isArray(refs.data.refs) ? refs.data.refs : [];
|
|
124
|
+
const main = list.find((x) => String(x.name ?? x.ref ?? '') === REF || String(x.name ?? x.ref ?? '') === 'main');
|
|
125
|
+
const sha = main !== undefined ? String(main.target ?? main.objectId ?? main.sha ?? main.commitSha ?? '') : '';
|
|
126
|
+
return { ok: true, head: sha === '' ? null : sha };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Corpus paths at HEAD. LIVE-MEASURED shape: `{ok, ref, entries:[{path,
|
|
130
|
+
* kind:'blob'|'tree', objectId}]}` — entries include tree rows, so filter to
|
|
131
|
+
* blobs. NO extension filter: the mirror must be a faithful cache of
|
|
132
|
+
* everything the pool holds. Dot-prefixed paths (the birth-seeded
|
|
133
|
+
* `.treedxkeep`) are EXCLUDED on purpose: `walkMirror` never stages them, so
|
|
134
|
+
* a baseline containing one would report a phantom deletion every pass and
|
|
135
|
+
* try to delete the service's own bookkeeping file.
|
|
136
|
+
*/
|
|
137
|
+
async function corpusPaths(client, repoId) {
|
|
138
|
+
// PAGINATION IS MANDATORY, not an optimization (live finding 2026-09-23):
|
|
139
|
+
// paths/list answers {entries, page:{limit,hasMore,nextCursor}} capped at
|
|
140
|
+
// 100 — reading one page silently truncated the corpus, and a machine whose
|
|
141
|
+
// twin had pushed the 101st file pulled "up to date" while missing real
|
|
142
|
+
// work. The provider must follow nextCursor until hasMore is false.
|
|
143
|
+
const out = [];
|
|
144
|
+
let cursor;
|
|
145
|
+
for (let page = 0; page < 500; page += 1) {
|
|
146
|
+
const res = await client.post(`/api/v1/repos/${encodeURIComponent(repoId)}/paths/list`, {
|
|
147
|
+
ref: REF,
|
|
148
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
149
|
+
});
|
|
150
|
+
if (!res.ok)
|
|
151
|
+
return [];
|
|
152
|
+
const raw = Array.isArray(res.data.entries) ? res.data.entries
|
|
153
|
+
: Array.isArray(res.data.paths) ? res.data.paths : [];
|
|
154
|
+
for (const e of raw) {
|
|
155
|
+
const p = String(typeof e === 'string' ? e : e.path ?? '');
|
|
156
|
+
const kind = typeof e === 'string' ? 'blob' : String(e.kind ?? 'blob');
|
|
157
|
+
if (p !== '' && kind === 'blob' && !p.split('/').some((seg) => seg.startsWith('.')))
|
|
158
|
+
out.push(p);
|
|
159
|
+
}
|
|
160
|
+
const pg = res.data.page;
|
|
161
|
+
if (pg?.hasMore !== true || pg.nextCursor === undefined || pg.nextCursor === cursor)
|
|
162
|
+
return out;
|
|
163
|
+
cursor = pg.nextCursor;
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Read one corpus file at HEAD. UTF-8 document read; base64 blob fallback.
|
|
169
|
+
* LIVE-MEASURED: a binary path answers `files/read` with 415
|
|
170
|
+
* `unsupported_media_type` — `blobs/read` (→ `blob.contentBase64`) is the
|
|
171
|
+
* binary path back.
|
|
172
|
+
*/
|
|
173
|
+
async function readCorpusFile(client, repoId, p) {
|
|
174
|
+
const doc = await client.post(`/api/v1/repos/${encodeURIComponent(repoId)}/files/read`, { ref: REF, path: p, parseFrontmatter: false });
|
|
175
|
+
if (doc.ok) {
|
|
176
|
+
const d = pick(doc.data, 'document', 'file') ?? doc.data;
|
|
177
|
+
const content = d.content;
|
|
178
|
+
if (typeof content === 'string') {
|
|
179
|
+
if (String(d.encoding ?? 'utf8') === 'base64')
|
|
180
|
+
return Buffer.from(content, 'base64');
|
|
181
|
+
return Buffer.from(content, 'utf8');
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
if (doc.code === 'unsupported_media_type' || doc.status === 415) {
|
|
186
|
+
const blob = await client.post(`/api/v1/repos/${encodeURIComponent(repoId)}/blobs/read`, { ref: REF, path: p });
|
|
187
|
+
if (blob.ok) {
|
|
188
|
+
const b = pick(blob.data, 'blob') ?? blob.data;
|
|
189
|
+
const b64 = String(b.contentBase64 ?? b.content ?? '');
|
|
190
|
+
if (b64 !== '')
|
|
191
|
+
return Buffer.from(b64, 'base64');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
async function fetchCorpus(r) {
|
|
197
|
+
const out = new Map();
|
|
198
|
+
for (const p of await corpusPaths(r.client, r.repoId)) {
|
|
199
|
+
const bytes = await readCorpusFile(r.client, r.repoId, p);
|
|
200
|
+
if (bytes !== null)
|
|
201
|
+
out.set(p, bytes);
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Create the provider (registered under kind 'treedx'). One instance per
|
|
207
|
+
* plane (host/engine); the mirror's state file carries everything across
|
|
208
|
+
* calls, so instances remain safe to share across cwds/projects.
|
|
209
|
+
*/
|
|
210
|
+
export function createTreedxProvider(cfgIn = {}) {
|
|
211
|
+
const cfg = { ...DEFAULTS, ...cfgIn };
|
|
212
|
+
const provider = {
|
|
213
|
+
kind: 'treedx',
|
|
214
|
+
describe: (project) => {
|
|
215
|
+
const s = readState(path.join(project.cwd, '.dsh-knowledge'));
|
|
216
|
+
const id = s?.repoId ?? project.repoId ?? '';
|
|
217
|
+
return `treedx · ${id !== '' ? `${id} · ` : ''}${s === null ? 'not cloned yet' : s.offline ? 'offline (push deferred)' : `head ${String(s.head ?? '—').slice(0, 10)}`}`;
|
|
218
|
+
},
|
|
219
|
+
async ensureClone(dir, remote) {
|
|
220
|
+
const state = readState(dir);
|
|
221
|
+
if (state !== null) {
|
|
222
|
+
if (state.origin !== remote.url) {
|
|
223
|
+
return { ok: false, code: 'origin-mismatch', detail: `mirror origin is ${state.origin}, the project's remote is ${remote.url}` };
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, detail: 'already synced (state present)' };
|
|
226
|
+
}
|
|
227
|
+
const res = await resolve(cfg, remote, null);
|
|
228
|
+
if (!res.ok)
|
|
229
|
+
return res.res;
|
|
230
|
+
const r = res.r;
|
|
231
|
+
const head = await headOf(r.client, r.repoId);
|
|
232
|
+
if (!head.ok)
|
|
233
|
+
return head.res;
|
|
234
|
+
// Materialize the corpus into the mirror (the same read surface git mode
|
|
235
|
+
// gives: real files the search/rules/index layers — and the read tool's
|
|
236
|
+
// sibling mirror paths — consume unchanged). A dirty non-empty dir with
|
|
237
|
+
// no state file is dead transport (gitops parity): moving it aside
|
|
238
|
+
// prevents its files being re-pushed as "new" content into a fresh pool.
|
|
239
|
+
const corpus = await fetchCorpus(r);
|
|
240
|
+
if (fs.existsSync(dir)) {
|
|
241
|
+
const strays = fs.readdirSync(dir);
|
|
242
|
+
if (strays.length > 0) {
|
|
243
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
244
|
+
fs.renameSync(dir, `${dir}.stale-${stamp}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
248
|
+
const baseline = {};
|
|
249
|
+
for (const [p, bytes] of corpus) {
|
|
250
|
+
const abs = path.join(dir, p);
|
|
251
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
252
|
+
fs.writeFileSync(abs, bytes);
|
|
253
|
+
baseline[p] = contentHash(bytes);
|
|
254
|
+
}
|
|
255
|
+
writeState(dir, { origin: remote.url, head: head.head, baseline, staged: null, offline: false, repoId: r.repoId });
|
|
256
|
+
return { ok: true, detail: `cloned from TreeDX (${corpus.size} file(s)${head.head === null ? ', empty repo' : ''})`, changed: true };
|
|
257
|
+
},
|
|
258
|
+
async initLocal(dir, remote) {
|
|
259
|
+
if (readState(dir) !== null)
|
|
260
|
+
return { ok: true, detail: 'already synced (state present)' };
|
|
261
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length > 0) {
|
|
262
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
263
|
+
fs.renameSync(dir, `${dir}.stale-${stamp}`);
|
|
264
|
+
}
|
|
265
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
266
|
+
writeState(dir, {
|
|
267
|
+
origin: remote?.url ?? '', head: null, baseline: {}, staged: null, offline: true,
|
|
268
|
+
});
|
|
269
|
+
return { ok: true, detail: 'TreeDX offline mirror initialized', changed: true };
|
|
270
|
+
},
|
|
271
|
+
async removeMirror(dir) {
|
|
272
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
273
|
+
return { ok: true, detail: 'mirror removed for rebuild' };
|
|
274
|
+
},
|
|
275
|
+
async stageAllAndCommit(dir, message, author) {
|
|
276
|
+
const state = readState(dir);
|
|
277
|
+
if (state === null)
|
|
278
|
+
return { ok: false, detail: 'no TreeDX state — ensureClone/initLocal first' };
|
|
279
|
+
const present = new Set(walkMirror(dir));
|
|
280
|
+
const changed = [];
|
|
281
|
+
for (const p of present) {
|
|
282
|
+
const bytes = fs.readFileSync(path.join(dir, p));
|
|
283
|
+
if (state.baseline[p] !== contentHash(bytes))
|
|
284
|
+
changed.push(p);
|
|
285
|
+
}
|
|
286
|
+
const dropped = Object.keys(state.baseline).filter((p) => !present.has(p));
|
|
287
|
+
if (changed.length === 0 && dropped.length === 0)
|
|
288
|
+
return { ok: true, detail: 'nothing to commit' };
|
|
289
|
+
// git-mode parity: a second stage of identical bytes is 'nothing to
|
|
290
|
+
// commit'. The baseline advances at STAGE time (push success recomputes
|
|
291
|
+
// it wholesale anyway; divergence replaces it), so the staged set is
|
|
292
|
+
// exactly the workdir-vs-last-known delta.
|
|
293
|
+
const baseline = { ...state.baseline };
|
|
294
|
+
for (const p of changed)
|
|
295
|
+
baseline[p] = contentHash(fs.readFileSync(path.join(dir, p)));
|
|
296
|
+
for (const p of dropped)
|
|
297
|
+
delete baseline[p];
|
|
298
|
+
writeState(dir, { ...state, baseline, staged: { message, author, paths: [...changed, ...dropped].sort() } });
|
|
299
|
+
return { ok: true, detail: `staged ${changed.length} path(s)${dropped.length > 0 ? `, ${dropped.length} removal(s)` : ''}`, changed: true };
|
|
300
|
+
},
|
|
301
|
+
async pullFastForward(dir, remote) {
|
|
302
|
+
const state = readState(dir);
|
|
303
|
+
if (state === null)
|
|
304
|
+
return { ok: false, detail: 'no TreeDX state' };
|
|
305
|
+
if (state.offline) {
|
|
306
|
+
// Recovery probe: still down ⇒ degrade again (§5.3). Reachable ⇒ the
|
|
307
|
+
// offline mirror's commits cannot rebase onto the service's history
|
|
308
|
+
// (ours live only in the store, which is the truth), so classify as
|
|
309
|
+
// diverged and let runSync's rebuild republish from the store —
|
|
310
|
+
// exactly the git mode's diverged-offline recovery.
|
|
311
|
+
const probe = await resolve(cfg, remote, state);
|
|
312
|
+
if (!probe.ok)
|
|
313
|
+
return probe.res;
|
|
314
|
+
const head = await headOf(probe.r.client, probe.r.repoId);
|
|
315
|
+
if (!head.ok)
|
|
316
|
+
return head.res;
|
|
317
|
+
return { ok: false, code: 'diverged', detail: 'offline mirror, TreeDX reachable again — rebuilding mirror from remote' };
|
|
318
|
+
}
|
|
319
|
+
const res = await resolve(cfg, remote, state);
|
|
320
|
+
if (!res.ok)
|
|
321
|
+
return res.res;
|
|
322
|
+
const r = res.r;
|
|
323
|
+
const head = await headOf(r.client, r.repoId);
|
|
324
|
+
if (!head.ok)
|
|
325
|
+
return head.res;
|
|
326
|
+
if (head.head === state.head && state.staged === null)
|
|
327
|
+
return { ok: true, detail: 'up to date (nothing to fast-forward)' };
|
|
328
|
+
if (head.head === state.head)
|
|
329
|
+
return { ok: true, detail: 'up to date (local work staged; push will ship it)' };
|
|
330
|
+
// Remote moved since our base. With staged work in hand, a refetch-merge
|
|
331
|
+
// could leave derived files (index/, topics/) built against a stale
|
|
332
|
+
// corpus — divergence REBUILDS from the store (runSync's path), and the
|
|
333
|
+
// rebuild re-publishes + re-derives. Without staged work, a plain ff
|
|
334
|
+
// refetch is all there is.
|
|
335
|
+
if (state.staged !== null) {
|
|
336
|
+
return { ok: false, code: 'diverged', detail: `TreeDX head moved (${String(state.head).slice(0, 10)} → ${String(head.head).slice(0, 10)}) with local work staged — rebuild will recover` };
|
|
337
|
+
}
|
|
338
|
+
const corpus = await fetchCorpus(r);
|
|
339
|
+
const baseline = {};
|
|
340
|
+
for (const [p, bytes] of corpus) {
|
|
341
|
+
const abs = path.join(dir, p);
|
|
342
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
343
|
+
fs.writeFileSync(abs, bytes);
|
|
344
|
+
baseline[p] = contentHash(bytes);
|
|
345
|
+
}
|
|
346
|
+
writeState(dir, { ...state, head: head.head, baseline });
|
|
347
|
+
return { ok: true, detail: `fast-forwarded (TreeDX ${String(head.head).slice(0, 10)}, ${corpus.size} file(s))`, changed: true };
|
|
348
|
+
},
|
|
349
|
+
async push(dir, remote) {
|
|
350
|
+
const state = readState(dir);
|
|
351
|
+
if (state === null)
|
|
352
|
+
return { ok: false, detail: 'no TreeDX state' };
|
|
353
|
+
if (state.staged === null || state.staged.paths.length === 0)
|
|
354
|
+
return { ok: true, detail: 'pushed (nothing new)' };
|
|
355
|
+
const res = await resolve(cfg, remote, state);
|
|
356
|
+
if (!res.ok)
|
|
357
|
+
return res.res;
|
|
358
|
+
const r = res.r;
|
|
359
|
+
let attempt = 0;
|
|
360
|
+
for (;;) {
|
|
361
|
+
const outcome = await onePush(r, dir, state, cfg);
|
|
362
|
+
if (outcome.retryLease === true && attempt < cfg.leaseRetries) {
|
|
363
|
+
attempt += 1;
|
|
364
|
+
await sleepOf(cfg)(cfg.leaseRetryDelayMs * attempt);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
return outcome.res;
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
return provider;
|
|
372
|
+
}
|
|
373
|
+
/** One push round: workspace → writes → commit. */
|
|
374
|
+
async function onePush(r, dir, state, cfg) {
|
|
375
|
+
const staged = state.staged;
|
|
376
|
+
if (staged === null || staged.paths.length === 0)
|
|
377
|
+
return { res: { ok: true, detail: 'pushed (nothing new)' } };
|
|
378
|
+
// An empty repo has no head to base on (live-gated assumption, FINDINGS):
|
|
379
|
+
// send baseRef only when one exists.
|
|
380
|
+
const createBody = { branchName: REF, mode: 'writable', ttlSeconds: cfg.workspaceTtlSeconds };
|
|
381
|
+
if (state.head !== null)
|
|
382
|
+
createBody.baseRef = REF;
|
|
383
|
+
const wsRes = await r.client.post(`/api/v1/repos/${encodeURIComponent(r.repoId)}/workspaces`, createBody);
|
|
384
|
+
if (!wsRes.ok) {
|
|
385
|
+
if (wsRes.status === 409)
|
|
386
|
+
return { res: { ok: false, code: 'rejected', detail: `workspace lease busy: ${wsRes.detail}` }, retryLease: true };
|
|
387
|
+
if (wsRes.code === 'auth')
|
|
388
|
+
return { res: { ok: false, code: 'auth', detail: wsRes.detail } };
|
|
389
|
+
return { res: { ok: false, code: 'network', detail: `workspace create: ${wsRes.detail}` } };
|
|
390
|
+
}
|
|
391
|
+
const wsData = pick(wsRes.data, 'workspace') ?? wsRes.data;
|
|
392
|
+
const wsId = String(wsData.workspaceId ?? wsData.id ?? '');
|
|
393
|
+
if (wsId === '')
|
|
394
|
+
return { res: { ok: false, detail: 'workspace create returned no id' } };
|
|
395
|
+
// LIVE-MEASURED: a committed workspace releases its lease, but a half-failed
|
|
396
|
+
// one holds it — and the lease survives its TTL until closed (measured:
|
|
397
|
+
// expiresAt passed, create still 409'd, /close released it). Every failure
|
|
398
|
+
// path after create must close, or a broken push poisons the pool's branch
|
|
399
|
+
// for every machine until an operator intervenes.
|
|
400
|
+
const fail = async (res, retryLease) => {
|
|
401
|
+
await r.client.post(`/api/v1/workspaces/${encodeURIComponent(wsId)}/close`, {}).catch(() => undefined);
|
|
402
|
+
return { res, ...(retryLease === true ? { retryLease } : {}) };
|
|
403
|
+
};
|
|
404
|
+
let wrote = 0;
|
|
405
|
+
for (const p of staged.paths) {
|
|
406
|
+
const abs = path.join(dir, p);
|
|
407
|
+
if (!fs.existsSync(abs)) {
|
|
408
|
+
const del = await r.client.del(`/api/v1/workspaces/${encodeURIComponent(wsId)}/files?path=${encodeURIComponent(p)}`);
|
|
409
|
+
if (!del.ok && del.status !== 404)
|
|
410
|
+
return await fail({ ok: false, detail: `overlay delete ${p}: ${del.detail}` });
|
|
411
|
+
wrote += 1;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const bytes = fs.readFileSync(abs);
|
|
415
|
+
if (isUtf8Safe(bytes)) {
|
|
416
|
+
const w = await r.client.put(`/api/v1/workspaces/${encodeURIComponent(wsId)}/files?path=${encodeURIComponent(p)}`, { encoding: 'utf8', content: bytes.toString('utf8') });
|
|
417
|
+
if (!w.ok) {
|
|
418
|
+
if (w.status === 413)
|
|
419
|
+
return await fail({ ok: false, detail: `file ${p} exceeds TreeDX's UTF-8 file limit: ${w.detail}` });
|
|
420
|
+
return await fail({ ok: false, detail: `overlay write ${p}: ${w.detail}` });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
// live-measured body: `contentBase64` (a `{encoding,content}` body is a 422)
|
|
425
|
+
const b = await r.client.post(`/api/v1/workspaces/${encodeURIComponent(wsId)}/blobs/write`, { path: p, contentBase64: bytes.toString('base64') });
|
|
426
|
+
if (!b.ok) {
|
|
427
|
+
if (b.status === 413)
|
|
428
|
+
return await fail({ ok: false, detail: `blob ${p} exceeds TreeDX's blob limit: ${b.detail}` });
|
|
429
|
+
return await fail({ ok: false, detail: `blob write ${p}: ${b.detail}` });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
wrote += 1;
|
|
433
|
+
}
|
|
434
|
+
const commit = await r.client.post(`/api/v1/workspaces/${encodeURIComponent(wsId)}/commit`, {
|
|
435
|
+
message: staged.message,
|
|
436
|
+
author: staged.author,
|
|
437
|
+
});
|
|
438
|
+
if (!commit.ok) {
|
|
439
|
+
// A 409 here is TreeDX failing closed on a moved ref / revoked lease.
|
|
440
|
+
// Classify by STATUS and CODE only — never by message prose (r35): the
|
|
441
|
+
// head moved underneath us ⇒ diverged-rebuild; lease ⇒ rejected-retry.
|
|
442
|
+
if (commit.status === 409) {
|
|
443
|
+
const head = await headOf(r.client, r.repoId);
|
|
444
|
+
if (head.ok && head.head !== state.head) {
|
|
445
|
+
return await fail({ ok: false, code: 'diverged', detail: `commit refused; TreeDX head moved (${String(state.head).slice(0, 10)} → ${String(head.head).slice(0, 10)}) — rebuild will recover` });
|
|
446
|
+
}
|
|
447
|
+
return await fail({ ok: false, code: 'rejected', detail: `commit conflict: ${commit.detail}` }, true);
|
|
448
|
+
}
|
|
449
|
+
return await fail({ ok: false, code: commit.code === 'auth' ? 'auth' : 'network', detail: `commit: ${commit.detail}` });
|
|
450
|
+
}
|
|
451
|
+
const cData = pick(commit.data, 'commit') ?? commit.data;
|
|
452
|
+
let newHead = (() => {
|
|
453
|
+
const s = cData.commitSha ?? cData.sha;
|
|
454
|
+
return typeof s === 'string' && s !== '' ? s : null;
|
|
455
|
+
})();
|
|
456
|
+
if (newHead === null) {
|
|
457
|
+
const after = await headOf(r.client, r.repoId);
|
|
458
|
+
if (after.ok)
|
|
459
|
+
newHead = after.head;
|
|
460
|
+
}
|
|
461
|
+
const baseline = { ...state.baseline };
|
|
462
|
+
for (const p of staged.paths) {
|
|
463
|
+
const abs = path.join(dir, p);
|
|
464
|
+
if (fs.existsSync(abs))
|
|
465
|
+
baseline[p] = contentHash(fs.readFileSync(abs));
|
|
466
|
+
else
|
|
467
|
+
delete baseline[p];
|
|
468
|
+
}
|
|
469
|
+
writeState(dir, {
|
|
470
|
+
...state, head: newHead ?? state.head, baseline, staged: null, offline: false,
|
|
471
|
+
});
|
|
472
|
+
return { res: { ok: true, detail: `pushed ${wrote} path(s) to TreeDX`, changed: true } };
|
|
473
|
+
}
|
|
474
|
+
/** The default instance registered at module load with production defaults;
|
|
475
|
+
* both planes re-register with user config at boot (src/index.ts, engine). */
|
|
476
|
+
const defaultProvider = createTreedxProvider();
|
|
477
|
+
registerProvider(defaultProvider);
|
|
478
|
+
export { defaultProvider as treedxProvider };
|
|
479
|
+
//# sourceMappingURL=provider.js.map
|