@awebai/oats 0.29.4 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -6
- package/bin/oats.mjs +194 -50
- package/docs/capabilities.md +160 -171
- package/docs/capability-manifest.schema.json +6 -11
- package/docs/configuration.md +213 -64
- package/docs/design/2026-09-16-knowledge-capability-contract.md +36 -50
- package/docs/design/2026-09-23-workspace-module-contracts.md +377 -544
- package/docs/design/2026-09-26-okf-knowledge-operations.md +132 -357
- package/docs/design/2026-09-27-team-model-v2.md +97 -117
- package/docs/design/2026-09-28-automations-trust.md +38 -0
- package/docs/design/2026-09-28-soul-launch-preference.md +63 -0
- package/docs/design/HISTORY.md +65 -0
- package/docs/design/README.md +23 -54
- package/docs/desktop-cli-api.md +1787 -1777
- package/docs/desktop.md +30 -91
- package/docs/execution-targets.md +146 -292
- package/docs/first-team.md +31 -17
- package/docs/implementation.md +77 -288
- package/docs/integrations.md +118 -320
- package/docs/knowledge-capability-authoring.md +25 -52
- package/docs/knowledge-reference/acceptance.md +3 -3
- package/docs/knowledge-reference/adoption.md +1 -1
- package/docs/knowledge-reference/harvester.md +2 -2
- package/docs/knowledge-reference/package-craft.md +3 -3
- package/docs/knowledge-reference/provider-mapping.md +3 -6
- package/docs/knowledge-reference/reader-capture.md +3 -3
- package/docs/knowledge-theory.md +62 -166
- package/docs/knowledge.md +225 -404
- package/docs/layers.md +42 -97
- package/docs/oats-local.schema.json +58 -5
- package/docs/oats-membership.schema.json +1 -8
- package/docs/oats-package.schema.json +5 -5
- package/docs/oats-workspace.schema.json +8 -22
- package/docs/official-catalog.md +25 -28
- package/docs/packages.md +45 -63
- package/docs/plans/0.30-close-out.md +83 -0
- package/docs/release-lane.md +82 -0
- package/docs/release-notes/oats-framework-v1.1.3.md +10 -8
- package/docs/release-notes/v0.19.0.md +48 -147
- package/docs/release-notes/v0.19.1.md +2 -3
- package/docs/release-notes/v0.19.3.md +2 -15
- package/docs/release-notes/v0.20.0.md +0 -15
- package/docs/release-notes/v0.22.0.md +71 -138
- package/docs/release-notes/v0.22.1.md +42 -90
- package/docs/release-notes/v0.22.10.md +1 -1
- package/docs/release-notes/v0.22.11.md +1 -47
- package/docs/release-notes/v0.22.12.md +4 -13
- package/docs/release-notes/v0.22.13.md +1 -42
- package/docs/release-notes/v0.22.14.md +3 -11
- package/docs/release-notes/v0.22.15.md +1 -46
- package/docs/release-notes/v0.22.16.md +6 -8
- package/docs/release-notes/v0.22.18.md +1 -99
- package/docs/release-notes/v0.22.19.md +3 -14
- package/docs/release-notes/v0.22.2.md +6 -15
- package/docs/release-notes/v0.22.3.md +0 -1
- package/docs/release-notes/v0.22.4.md +1 -14
- package/docs/release-notes/v0.22.5.md +2 -12
- package/docs/release-notes/v0.22.6.md +0 -3
- package/docs/release-notes/v0.23.0.md +9 -25
- package/docs/release-notes/v0.23.1.md +9 -25
- package/docs/release-notes/v0.23.2.md +2 -4
- package/docs/release-notes/v0.24.0.md +56 -97
- package/docs/release-notes/v0.24.1.md +7 -11
- package/docs/release-notes/v0.24.10.md +34 -45
- package/docs/release-notes/v0.24.11.md +12 -20
- package/docs/release-notes/v0.24.12.md +35 -48
- package/docs/release-notes/v0.24.13.md +34 -41
- package/docs/release-notes/v0.24.2.md +9 -13
- package/docs/release-notes/v0.24.3.md +7 -11
- package/docs/release-notes/v0.24.4.md +6 -6
- package/docs/release-notes/v0.24.5.md +6 -10
- package/docs/release-notes/v0.24.6.md +2 -5
- package/docs/release-notes/v0.24.7.md +46 -75
- package/docs/release-notes/v0.24.8.md +58 -96
- package/docs/release-notes/v0.24.9.md +38 -54
- package/docs/release-notes/v0.25.0.md +59 -76
- package/docs/release-notes/v0.25.1.md +57 -81
- package/docs/release-notes/v0.25.2.md +51 -70
- package/docs/release-notes/v0.25.3.md +11 -13
- package/docs/release-notes/v0.25.4.md +9 -13
- package/docs/release-notes/v0.25.5.md +3 -5
- package/docs/release-notes/v0.25.6.md +20 -29
- package/docs/release-notes/v0.25.7.md +5 -7
- package/docs/release-notes/v0.25.8.md +26 -39
- package/docs/release-notes/v0.26.0.md +175 -646
- package/docs/release-notes/v0.27.0.md +4 -5
- package/docs/release-notes/v0.27.1.md +4 -6
- package/docs/release-notes/v0.27.2.md +1 -1
- package/docs/release-notes/v0.28.0.md +57 -124
- package/docs/release-notes/v0.29.0.md +89 -208
- package/docs/release-notes/v0.29.1.md +1 -1
- package/docs/release-notes/v0.29.2.md +3 -4
- package/docs/release-notes/v0.30.0.md +205 -0
- package/docs/release-notes/v0.30.1.md +123 -0
- package/docs/schedules.md +280 -363
- package/docs/servers.md +99 -117
- package/docs/soul.schema.json +2 -9
- package/docs/souls-and-instances.md +145 -158
- package/docs/workspaces.md +137 -215
- package/lib/automations.mjs +21 -6
- package/lib/core.mjs +226 -74
- package/lib/instance-events.mjs +1 -1
- package/lib/instance-inspect.mjs +109 -34
- package/lib/instance-lifecycle.mjs +14 -1
- package/lib/instance-resolution.mjs +26 -27
- package/lib/launch-preference.mjs +87 -0
- package/lib/materialize.mjs +3 -3
- package/lib/packages.mjs +1 -1
- package/lib/resolve.mjs +30 -88
- package/lib/schedule.mjs +1 -1
- package/lib/teams-verbs.mjs +195 -0
- package/lib/teams.mjs +190 -0
- package/lib/triggers.mjs +2 -2
- package/lib/workspace.mjs +54 -147
- package/package-catalog.json +10 -16
- package/package.json +1 -3
- package/skills/oats-getting-started/SKILL.md +25 -13
- package/capabilities/oats-authoring/LICENSE +0 -21
- package/capabilities/oats-authoring/oats-package.json +0 -11
- package/capabilities/oats-authoring/oats.json +0 -12
- package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +0 -84
- package/capabilities/oats-authoring/skills/skill-craft/SKILL.md +0 -109
- package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +0 -116
- package/capabilities/oats-aweb/bin/oats-aweb-binding.mjs +0 -11
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +0 -1338
- package/capabilities/oats-aweb/injects/aweb.md +0 -47
- package/capabilities/oats-aweb/lib/binding-wire.mjs +0 -356
- package/capabilities/oats-aweb/lib/captured-execution.mjs +0 -91
- package/capabilities/oats-aweb/lib/captured-native.mjs +0 -91
- package/capabilities/oats-aweb/lib/grant-custody.mjs +0 -38
- package/capabilities/oats-aweb/lib/invocation-shape.mjs +0 -135
- package/capabilities/oats-aweb/lib/portable-binding.mjs +0 -146
- package/capabilities/oats-aweb/lib/session-readiness.mjs +0 -56
- package/capabilities/oats-aweb/lib/wake-receive.mjs +0 -56
- package/capabilities/oats-aweb/oats.json +0 -208
- package/capabilities/oats-aweb/skills/LICENSE +0 -21
- package/capabilities/oats-aweb/skills/VENDORED.md +0 -31
- package/capabilities/oats-aweb/skills/aweb-identity/SKILL.md +0 -201
- package/capabilities/oats-aweb/skills/aweb-messaging/SKILL.md +0 -161
- package/capabilities/oats-aweb/skills/aweb-messaging/references/messaging-scenarios.md +0 -61
- package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +0 -116
- package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +0 -74
- package/capabilities/oats-aweb/skills/oats-aweb/SKILL.md +0 -216
- package/capabilities/oats-jira/bin/oats-jira.mjs +0 -40
- package/capabilities/oats-jira/injects/jira.md +0 -10
- package/capabilities/oats-jira/oats.json +0 -22
- package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +0 -179
- package/capabilities/oats-linear/bin/oats-linear-hook.mjs +0 -34
- package/capabilities/oats-linear/bin/oats-linear.mjs +0 -344
- package/capabilities/oats-linear/injects/linear.md +0 -8
- package/capabilities/oats-linear/oats.json +0 -24
- package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +0 -223
- package/capabilities/oats-okf/bin/oats-okf-binding.mjs +0 -14
- package/capabilities/oats-okf/bin/oats-okf.mjs +0 -209
- package/capabilities/oats-okf/injects/okf.md +0 -42
- package/capabilities/oats-okf/lib/binding-wire.mjs +0 -348
- package/capabilities/oats-okf/lib/captured-worker.mjs +0 -109
- package/capabilities/oats-okf/lib/config.mjs +0 -124
- package/capabilities/oats-okf/lib/consult.mjs +0 -518
- package/capabilities/oats-okf/lib/harvest-status.mjs +0 -88
- package/capabilities/oats-okf/lib/harvest-switch.mjs +0 -94
- package/capabilities/oats-okf/lib/inspection.mjs +0 -119
- package/capabilities/oats-okf/lib/invocation-context.mjs +0 -111
- package/capabilities/oats-okf/lib/invocation-shape.mjs +0 -135
- package/capabilities/oats-okf/lib/io.mjs +0 -118
- package/capabilities/oats-okf/lib/migration.mjs +0 -137
- package/capabilities/oats-okf/lib/okf-validate.mjs +0 -123
- package/capabilities/oats-okf/lib/portable-binding.mjs +0 -199
- package/capabilities/oats-okf/lib/source-contract.mjs +0 -46
- package/capabilities/oats-okf/lib/sources.mjs +0 -424
- package/capabilities/oats-okf/lib/stores.mjs +0 -473
- package/capabilities/oats-okf/lib/worker.mjs +0 -497
- package/capabilities/oats-okf/oats.json +0 -148
- package/capabilities/oats-okf/schemas/okf-base.schema.json +0 -46
- package/capabilities/oats-okf/schemas/okf-bindings.schema.json +0 -112
- package/capabilities/oats-okf/schemas/okf-portable-declaration.schema.json +0 -87
- package/capabilities/oats-okf/schemas/okf-portable-payload.schema.json +0 -113
- package/capabilities/oats-okf/schemas/okf-soul.schema.json +0 -37
- package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +0 -144
- package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +0 -86
- package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +0 -104
- package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +0 -140
- package/capabilities/oats-okf-harvest/injects/harvester.md +0 -12
- package/capabilities/oats-okf-harvest/oats.json +0 -26
- package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +0 -168
- package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +0 -192
- package/capabilities/oats-okf-harvest/skills/okf-authoring/SKILL.md +0 -151
- package/capabilities/oats-okf-harvest/skills/okf-authoring/scripts/okf-validate.mjs +0 -123
- package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +0 -170
- package/capabilities/oats-okf-maintenance/injects/maintainer.md +0 -12
- package/capabilities/oats-okf-maintenance/lib/provenance.mjs +0 -50
- package/capabilities/oats-okf-maintenance/oats.json +0 -21
- package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +0 -159
- package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +0 -192
- package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +0 -151
- package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +0 -123
- package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +0 -146
- package/capabilities/oats-review/injects/review.md +0 -69
- package/capabilities/oats-review/oats.json +0 -10
- package/capabilities/oats-review/skills/code-review/SKILL.md +0 -44
- package/capabilities/oats-review/skills/security-review/SKILL.md +0 -59
- package/docs/conventions.md +0 -90
- package/docs/design/2026-09-07-architecture-reassessment.md +0 -131
- package/docs/design/2026-09-07-desktop-souls-capabilities.md +0 -50
- package/docs/design/2026-09-07-mobile-agent-management-proposal.md +0 -228
- package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +0 -558
- package/docs/design/2026-09-13-knowledge-and-memory-direction.md +0 -744
- package/docs/design/2026-09-13-knowledge-implementation.md +0 -127
- package/docs/design/2026-09-13-knowledge-location-contract.md +0 -340
- package/docs/design/2026-09-14-artifact-retention-contract.md +0 -190
- package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +0 -708
- package/docs/design/2026-09-14-portable-souls-contract-amendments.md +0 -85
- package/docs/design/2026-09-14-portable-souls-explainer.md +0 -750
- package/docs/design/2026-09-15-captured-dispatch.md +0 -127
- package/docs/design/2026-09-15-captured-resolution-records.md +0 -143
- package/docs/design/2026-09-15-package-preparation.md +0 -100
- package/docs/design/2026-09-15-portable-data-contract.md +0 -121
- package/docs/design/2026-09-15-portable-declarations.md +0 -189
- package/docs/design/2026-09-15-portable-souls-handoff.md +0 -150
- package/docs/design/2026-09-15-portable-souls-implementation.md +0 -417
- package/docs/design/2026-09-15-selection-lock-and-approval.md +0 -122
- package/docs/design/2026-09-15-source-observation.md +0 -119
- package/docs/design/2026-09-16-captured-admission.md +0 -77
- package/docs/design/2026-09-16-captured-helper-dispatch.md +0 -105
- package/docs/design/2026-09-16-captured-launch-inputs.md +0 -42
- package/docs/design/2026-09-16-command-profile-preparation.md +0 -86
- package/docs/design/2026-09-16-fresh-install-first-rollout.md +0 -47
- package/docs/design/2026-09-16-fresh-operator-walkthrough.md +0 -282
- package/docs/design/2026-09-16-messaging-capability-contract.md +0 -59
- package/docs/design/2026-09-16-portable-migration-evidence.md +0 -158
- package/docs/design/2026-09-16-portable-onboarding.md +0 -179
- package/docs/design/2026-09-16-prepare-request-transport.md +0 -26
- package/docs/design/2026-09-16-provider-binding-codecs.md +0 -98
- package/docs/design/2026-09-16-provider-binding-wire.md +0 -274
- package/docs/design/2026-09-17-capability-helper-input-contract.md +0 -95
- package/docs/design/2026-09-17-captured-backend-parity.md +0 -53
- package/docs/design/2026-09-17-captured-native-start.md +0 -58
- package/docs/design/2026-09-17-portable-boundary-hookup.md +0 -19
- package/docs/design/2026-09-17-portable-boundary-resources.md +0 -52
- package/docs/design/2026-09-17-public-captured-start.md +0 -108
- package/docs/design/2026-09-17-public-prepare-request.md +0 -90
- package/docs/design/2026-09-18-captured-pi-host.md +0 -205
- package/docs/design/2026-09-18-first-cut-release-checklist.md +0 -131
- package/docs/design/2026-09-18-herdr-protocol-compatibility.md +0 -60
- package/docs/design/2026-09-20-redesign-program-board.md +0 -142
- package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +0 -289
- package/docs/design/2026-09-20-workspace-onboarding-public.md +0 -207
- package/docs/design/2026-09-22-desktop-parity-seams.md +0 -58
- package/docs/design/2026-09-23-simplified-workspace-model.md +0 -711
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +0 -65
- package/docs/design/2026-09-24-desktop-phase-f-boundary.md +0 -242
- package/docs/design/2026-09-24-phase-d-plan.md +0 -305
- package/docs/design/2026-09-25-teams-contract.md +0 -258
- package/docs/design/2026-09-26-desktop-design-brief-architecture.md +0 -241
- package/docs/design/desktop-ux-plan.md +0 -362
- package/docs/design/launch-configurations.md +0 -168
- package/docs/design/okf-mirror-provenance.md +0 -105
- package/docs/design/operations-contract.md +0 -141
- package/docs/oats-member.schema.json +0 -38
- package/skills/integration-authoring/SKILL.md +0 -84
- package/skills/oats-support/SKILL.md +0 -79
- package/skills/skill-craft/SKILL.md +0 -109
- package/skills/soul-craft/SKILL.md +0 -116
|
@@ -1,473 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { fs, join, dirname, safePath, readJSON, save, atomic, tree, materialize, digest, hash, withLock, exec, cleanEnv, fail, relPath, overlaps, resolve, within, redactUrls, displayRepo } from './io.mjs';
|
|
6
|
-
import { metadata, noGit, gitTimeoutMs } from './config.mjs';
|
|
7
|
-
const validator = fileURLToPath(new URL('./okf-validate.mjs', import.meta.url));
|
|
8
|
-
// Never let local replace refs reinterpret frozen OIDs, including inside Git's
|
|
9
|
-
// transport subprocesses. Override even an explicitly supplied command env.
|
|
10
|
-
export const gitEnv = (env = cleanEnv()) => ({...env,GIT_NO_REPLACE_OBJECTS:'1'});
|
|
11
|
-
export const git = (cwd,args,opts={}) => exec('git',['--no-replace-objects','-c','core.hooksPath=/dev/null','-c','protocol.ext.allow=never','-C',cwd,...args],{cwd,...opts,env:gitEnv(opts.env)});
|
|
12
|
-
function baseError(code,message,base,alias,step,reason) {throw Object.assign(new Error(redactUrls(message)),{code,base:alias,repository:displayRepo(base.repository),step,reason});}
|
|
13
|
-
function baseRemedy(alias) {return `fix the binding for base alias "${alias}" in the bindings file, or remove the base from the bindings`;}
|
|
14
|
-
function classifyGitFailure(error) {
|
|
15
|
-
const text=String(error?.message || '');
|
|
16
|
-
if(error?.code==='ETIMEDOUT' || /ETIMEDOUT|timed out|timeout/i.test(text)) return 'timeout';
|
|
17
|
-
if(/authentication|permission denied|publickey|access denied|could not read Username|authorization|not authorized/i.test(text)) return 'auth';
|
|
18
|
-
if(/not found|repository .*not exist|does not exist|no such (file|repository)|couldn't find remote ref|Repository not found/i.test(text)) return 'not-found';
|
|
19
|
-
if(/could not resolve host|failed to connect|connection refused|network is unreachable|no route to host|proxy/i.test(text)) return 'network';
|
|
20
|
-
return 'unknown';
|
|
21
|
-
}
|
|
22
|
-
export function unavailable(base,alias,step,error) {
|
|
23
|
-
const reason=classifyGitFailure(error),detail=reason==='unknown'?`; original Git failure: ${redactUrls(String(error?.message || 'unknown failure'))}`:'';
|
|
24
|
-
const message=`Git base "${alias}" repository "${displayRepo(base.repository)}" is required by the deployment's bindings, but ${step} failed (reason: ${reason}${detail}); ${baseRemedy(alias)}`;
|
|
25
|
-
baseError('E_BASE_UNAVAILABLE',message,base,alias,step,reason);
|
|
26
|
-
}
|
|
27
|
-
export function requireNotShallow(base,alias,cwd) {
|
|
28
|
-
const shallow=git(cwd,['rev-parse','--is-shallow-repository']);
|
|
29
|
-
if(shallow==='true') baseError('E_BASE_SHALLOW',`Git base "${alias}" repository "${displayRepo(base.repository)}" is required by the deployment's bindings, but the repository is shallow; oats.okf requires full accepted history before staging; ${baseRemedy(alias)}`,base,alias,'clone','shallow');
|
|
30
|
-
}
|
|
31
|
-
export function preflightLocalRepository(base,alias) {
|
|
32
|
-
if(!base.repository.startsWith('/')) return;
|
|
33
|
-
if(!fs.existsSync(base.repository)) unavailable(base,alias,'clone',Object.assign(new Error('repository not found'),{code:'ENOENT'}));
|
|
34
|
-
try { requireNotShallow(base,alias,base.repository); }
|
|
35
|
-
catch(e) { if(e.code==='E_BASE_SHALLOW') throw e; unavailable(base,alias,'clone',e); }
|
|
36
|
-
}
|
|
37
|
-
export const baseLock = b => `${b.path}.okf-lock`;
|
|
38
|
-
// okf consult readers hold a directory base's lock briefly; staging and
|
|
39
|
-
// publication queue behind them for a bounded time instead of failing.
|
|
40
|
-
const CONSULT_READER_WAIT_MS = 10000;
|
|
41
|
-
export const journalPath = b => `${b.path}.okf-publication.json`;
|
|
42
|
-
export function validateBase(root, base) {
|
|
43
|
-
const files=tree(root,{git:base.kind==='git' && base.root==='.'}); const meta=metadata(files,base);
|
|
44
|
-
const result=JSON.parse(exec(process.execPath,[validator,root,'--strict','--json'],{acceptedStatus:[0,1]}));
|
|
45
|
-
if(result.errors.length || result.warnings.length) fail('E_VALIDATION', [...result.errors,...result.warnings].join('; '));
|
|
46
|
-
return {files,meta,digest:digest(files)};
|
|
47
|
-
}
|
|
48
|
-
/** Write a blob straight to a file descriptor: no in-memory buffer, so object
|
|
49
|
-
* size never limits what a base may hold. The remote budget applies because a
|
|
50
|
-
* partial clone fetches a missing blob on its first read. */
|
|
51
|
-
function writeBlob(cwd,oid,target,mode) {
|
|
52
|
-
const fd=fs.openSync(target,'wx',mode);
|
|
53
|
-
let result; try { result=spawnSync('git',['--no-replace-objects','-c','core.hooksPath=/dev/null','-C',cwd,'cat-file','blob',oid],{cwd,env:gitEnv(),timeout:gitTimeoutMs(),stdio:['ignore',fd,'pipe']}); } finally { fs.closeSync(fd); }
|
|
54
|
-
if(result.error || result.status!==0) fail('E_COMMAND','Git object read failed');
|
|
55
|
-
fs.chmodSync(target,mode);
|
|
56
|
-
}
|
|
57
|
-
/** A tree entry the store never materializes: outside the knowledge base root.
|
|
58
|
-
* Its absence from a staging tree is by construction; its presence is a
|
|
59
|
-
* worker's doing and is judged like any other change. */
|
|
60
|
-
const outsideBase=(base,p)=>!(base.root==='.' || p===base.root || p.startsWith(base.root+'/'));
|
|
61
|
-
function materializeGitObjects(base,dest,head) {
|
|
62
|
-
immutableCommit(dest,head);
|
|
63
|
-
const entries=gitTreeEntries(dest,head);
|
|
64
|
-
// Preflight paths/modes before materialization. No checkout/smudge/process/
|
|
65
|
-
// working-tree-encoding conversion: accepted bytes come directly from OIDs.
|
|
66
|
-
for(const [p,entry] of entries) {
|
|
67
|
-
relPath(p);const inBase=base.root==='.' || p===base.root || p.startsWith(base.root+'/');
|
|
68
|
-
if(!['100644','100755','120000','160000'].includes(entry.mode) || (entry.mode==='160000'?entry.type!=='commit':entry.type!=='blob')) fail('E_PATH','unsupported Git tree entry');
|
|
69
|
-
if(inBase && !['100644','100755'].includes(entry.mode)) fail('E_PATH','symlink or submodule in knowledge base is not allowed');
|
|
70
|
-
}
|
|
71
|
-
// Index/HEAD updates do not apply content filters. They preserve the normal
|
|
72
|
-
// Git worktree needed by existing diff/publication guards without checkout.
|
|
73
|
-
git(dest,['read-tree',head]);git(dest,['update-ref','--no-deref','HEAD',head]);
|
|
74
|
-
// Only the knowledge base is materialized: the index carries the whole tree
|
|
75
|
-
// for publication, but bytes outside the base root are never read, so the
|
|
76
|
-
// size of the rest of the repository does not matter. The scope checks know
|
|
77
|
-
// that an entry outside the root is expected to be absent (see outsideBase).
|
|
78
|
-
for(const [p,entry] of entries) {
|
|
79
|
-
if(outsideBase(base,p)) continue;
|
|
80
|
-
// relPath above already refused traversal; containment is asserted again
|
|
81
|
-
// on the resolved target before any write (okf 4.0.1 #1).
|
|
82
|
-
const target=resolve(dest,p);if(target===resolve(dest) || !within(dest,target)) fail('E_PATH','Git tree entry escapes the stage');safePath(target);fs.mkdirSync(dirname(target),{recursive:true});
|
|
83
|
-
writeBlob(dest,entry.oid,target,entry.mode==='100755'?0o755:0o644);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
function clone(base, dest, selectedHead, { alias = base.id } = {}) {
|
|
87
|
-
safePath(dest);
|
|
88
|
-
if(fs.existsSync(dest)) fail('E_PATH',`staging destination exists: ${dest}`);
|
|
89
|
-
preflightLocalRepository(base,alias);
|
|
90
|
-
fs.mkdirSync(dirname(dest),{recursive:true});
|
|
91
|
-
// Fetch only what the store reads: the accepted branch, trees now and blobs
|
|
92
|
-
// on demand. Only a remote that does not offer object filtering gets a plain
|
|
93
|
-
// single-branch clone instead; any other failure is the failure it is. The
|
|
94
|
-
// ancestry the publication checks walk is present either way.
|
|
95
|
-
const cloneArgs=['clone','--no-hardlinks','--no-checkout','--single-branch','--branch',base.acceptedBranch];
|
|
96
|
-
try { git(dirname(dest),[...cloneArgs,'--filter=blob:none','--',base.repository,dest],{timeout:gitTimeoutMs()}); }
|
|
97
|
-
catch(e) {
|
|
98
|
-
if(e.code==='E_COMMAND' && /filter/i.test(e.message)) {
|
|
99
|
-
fs.rmSync(dest,{recursive:true,force:true});
|
|
100
|
-
try { git(dirname(dest),[...cloneArgs,'--',base.repository,dest],{timeout:gitTimeoutMs()}); }
|
|
101
|
-
catch(error) { unavailable(base,alias,'clone',error); }
|
|
102
|
-
} else unavailable(base,alias,'clone',e);
|
|
103
|
-
}
|
|
104
|
-
try { requireNotShallow(base,alias,dest); }
|
|
105
|
-
catch(e) { if(e.code==='E_BASE_SHALLOW') throw e; unavailable(base,alias,'clone',e); }
|
|
106
|
-
try { git(dest,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()}); }
|
|
107
|
-
catch(e) { unavailable(base,alias,'fetch',e); }
|
|
108
|
-
const head=selectedHead ?? git(dest,['rev-parse','FETCH_HEAD']);
|
|
109
|
-
try { requireNotShallow(base,alias,dest);verifyRemote(base,dest);materializeGitObjects(base,dest,head); }
|
|
110
|
-
catch(e) { if(['E_BASE_SHALLOW','E_OWNER','E_CONFIRM','E_PATH','E_BASE','E_VALIDATION'].includes(e.code)) throw e; unavailable(base,alias,'checkout',e); }
|
|
111
|
-
// Reject a linked bundle even if Git happily checked the link out.
|
|
112
|
-
safePath(join(dest,base.root));
|
|
113
|
-
return head;
|
|
114
|
-
}
|
|
115
|
-
export function stageBase(base, dest, { alias = base.id } = {}) {
|
|
116
|
-
safePath(dest);
|
|
117
|
-
if(fs.existsSync(dest)) fail('E_PATH',`staging destination exists: ${dest}`);
|
|
118
|
-
const acceptedPath=base.kind==='directory'?base.path:(base.repository.startsWith('/')?resolve(base.repository,base.root):null);
|
|
119
|
-
if(acceptedPath && overlaps(acceptedPath,dest)) fail('E_PATH','stage overlaps accepted base');
|
|
120
|
-
if(base.kind==='git') {
|
|
121
|
-
const head=clone(base,dest,undefined,{alias}); const root=join(dest,base.root);
|
|
122
|
-
const validated=validateBase(root,base);
|
|
123
|
-
verifyPublicationTree(base,dest,head,head,validated.files);
|
|
124
|
-
return { ...validated, head, root, checkout:dest };
|
|
125
|
-
}
|
|
126
|
-
noGit(base.path);
|
|
127
|
-
return withLock(baseLock(base),()=>{
|
|
128
|
-
if(fs.existsSync(journalPath(base))) fail('E_RECOVERY',`publication pending: ${journalPath(base)}; retry its recorded run before reading`);
|
|
129
|
-
const result=validateBase(base.path,base); materialize(dest,result.files);
|
|
130
|
-
return {...result,root:dest};
|
|
131
|
-
},{waitMs:CONSULT_READER_WAIT_MS});
|
|
132
|
-
}
|
|
133
|
-
export function allowedChanges(before, after, meta, owned) {
|
|
134
|
-
const changed=[...new Set([...Object.keys(before),...Object.keys(after)])].filter(p=>before[p]!==after[p]);
|
|
135
|
-
if(changed.includes('okf-base.json')) fail('E_OWNER','harvest cannot change base identity/ownership');
|
|
136
|
-
const paths=owned.map(n=>meta.nodes[n]?.path || fail('E_OWNER',`unknown owned node ${n}`));
|
|
137
|
-
for(const p of changed) if(!['index.md','log.md'].includes(p) && !paths.some(n=>p.startsWith(n+'/'))) fail('E_OWNER',`unauthorized touched path: ${p}`);
|
|
138
|
-
// Base navigation can only gain/remove listings for this worker's nodes.
|
|
139
|
-
if(changed.includes('index.md')) {
|
|
140
|
-
const filter=value=>Buffer.from(value || '', 'base64').toString().split('\n').filter(l=>!paths.some(p=>l.includes(`](${p}/`) || l.includes(`](/${p}/`))).join('\n');
|
|
141
|
-
if(filter(before['index.md'])!==filter(after['index.md'])) fail('E_OWNER','base index edits must be navigation for owned nodes only');
|
|
142
|
-
}
|
|
143
|
-
if(changed.includes('log.md')) {
|
|
144
|
-
const old=Buffer.from(before['log.md']||'','base64').toString();
|
|
145
|
-
const next=Buffer.from(after['log.md']||'','base64').toString();
|
|
146
|
-
if(!next.includes(old)) fail('E_OWNER','base log is append/prepend-only, preserve history bytes');
|
|
147
|
-
}
|
|
148
|
-
return changed;
|
|
149
|
-
}
|
|
150
|
-
const INDEX_SNAPSHOT_LIMIT=16*1024*1024;
|
|
151
|
-
const indexFailure=()=>fail('E_INDEX','Git verification requires a stable regular self-contained index within 16 MiB');
|
|
152
|
-
function readVerificationIndex(source) {
|
|
153
|
-
let fd;
|
|
154
|
-
try {
|
|
155
|
-
const before=fs.lstatSync(safePath(source),{bigint:true});
|
|
156
|
-
const regular=stat=>stat.isFile() && stat.size>=0n && stat.size<=BigInt(INDEX_SNAPSHOT_LIMIT);
|
|
157
|
-
const same=stat=>regular(stat) && ['dev','ino','size','mtimeNs','ctimeNs'].every(key=>stat[key]===before[key]);
|
|
158
|
-
if(!regular(before) || typeof fs.constants.O_NOFOLLOW!=='number' || typeof fs.constants.O_NONBLOCK!=='number') indexFailure();
|
|
159
|
-
// lstat alone races. NONBLOCK prevents a swapped FIFO from hanging open;
|
|
160
|
-
// NOFOLLOW plus descriptor identity/type checks precede every byte read.
|
|
161
|
-
fd=fs.openSync(source,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW|fs.constants.O_NONBLOCK);
|
|
162
|
-
if(!same(fs.fstatSync(fd,{bigint:true})) || !same(fs.lstatSync(safePath(source),{bigint:true}))) indexFailure();
|
|
163
|
-
const bytes=Buffer.alloc(Number(before.size));
|
|
164
|
-
for(let offset=0;offset<bytes.length;) {
|
|
165
|
-
const count=fs.readSync(fd,bytes,offset,bytes.length-offset,offset);
|
|
166
|
-
if(!count) indexFailure();offset+=count;
|
|
167
|
-
}
|
|
168
|
-
if(!same(fs.fstatSync(fd,{bigint:true})) || !same(fs.lstatSync(safePath(source),{bigint:true}))) indexFailure();
|
|
169
|
-
return bytes;
|
|
170
|
-
} catch {indexFailure();}
|
|
171
|
-
finally {if(fd!==undefined) fs.closeSync(fd);}
|
|
172
|
-
}
|
|
173
|
-
function withVerificationIndex(cwd,fn) {
|
|
174
|
-
// Git 2.44 diff refreshes even with optional locks disabled. Use a COPY,
|
|
175
|
-
// never a worker-index write/restore or the private publication index.
|
|
176
|
-
const scratch=fs.mkdtempSync(join(fs.realpathSync(tmpdir()),'oats-okf-verify-index-')),owned=fs.lstatSync(scratch);
|
|
177
|
-
try {
|
|
178
|
-
const index=join(scratch,'index'),source=resolve(cwd,git(cwd,['rev-parse','--git-path','index']));
|
|
179
|
-
fs.writeFileSync(index,readVerificationIndex(source),{flag:'wx',mode:0o600});
|
|
180
|
-
const env={...cleanEnv(),GIT_INDEX_FILE:index},format=git(cwd,['rev-parse','--show-object-format']);
|
|
181
|
-
if(!['sha1','sha256'].includes(format)) indexFailure();
|
|
182
|
-
// Native split-index parsing freshens backing files BEFORE splitIndex=false
|
|
183
|
-
// takes effect. Preflight in an EMPTY private gitdir: original sharedindex
|
|
184
|
-
// files are never searched/opened, and missing dependencies refuse. Merely
|
|
185
|
-
// putting a backing copy beside GIT_INDEX_FILE would not isolate that read.
|
|
186
|
-
const gitdir=join(scratch,'repository');
|
|
187
|
-
git(scratch,['init','--bare','--quiet','--template=',`--object-format=${format}`,gitdir]);
|
|
188
|
-
try {
|
|
189
|
-
const shared=git(scratch,['--no-optional-locks','-c','core.splitIndex=false','-c','core.fsmonitor=false','rev-parse','--shared-index-path'],{env:{...env,GIT_DIR:gitdir}});
|
|
190
|
-
if(shared) indexFailure();
|
|
191
|
-
} catch {indexFailure();}
|
|
192
|
-
return fn(args=>git(cwd,['--no-optional-locks','-c','diff.autoRefreshIndex=true','-c','core.splitIndex=false',...args],{env}));
|
|
193
|
-
} finally {
|
|
194
|
-
const current=fs.lstatSync(scratch);
|
|
195
|
-
if(!current.isDirectory() || current.dev!==owned.dev || current.ino!==owned.ino) fail('E_INDEX','Git verification scratch ownership changed; cleanup refused');
|
|
196
|
-
fs.rmSync(scratch,{recursive:true,force:true});
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
export function verifyGitScope(base, dest, baseline, { checkModes = true } = {}) {
|
|
200
|
-
return withVerificationIndex(dest,read=>{
|
|
201
|
-
// Entries outside the root are never materialized, so their absence is
|
|
202
|
-
// not a deletion; a present outside file that differs still is a change.
|
|
203
|
-
const names=read(['diff','--name-only','-z',baseline,'--']).split('\0').filter(Boolean).filter(p=>!(outsideBase(base,p) && !fs.existsSync(join(dest,p))));
|
|
204
|
-
// A restored working file can conceal a staged, unauthorized index entry.
|
|
205
|
-
names.push(...read(['diff','--cached','--name-only','-z',baseline,'--']).split('\0').filter(Boolean));
|
|
206
|
-
names.push(...read(['ls-files','--others','--exclude-standard','-z']).split('\0').filter(Boolean));
|
|
207
|
-
if(base.root!=='.' && names.some(p=>!p.startsWith(base.root+'/'))) fail('E_OWNER','Git worker touched files outside its knowledge base');
|
|
208
|
-
// --others without exclude-standard includes ignored additions as well.
|
|
209
|
-
// Unchanged code symlinks outside the base are not knowledge and need not be
|
|
210
|
-
// traversed. Bundle symlinks are rejected by validateBase/tree.
|
|
211
|
-
const extra=read(['ls-files','--others','-z']).split('\0').filter(Boolean);
|
|
212
|
-
if(base.root!=='.' && extra.some(p=>!p.startsWith(base.root+'/'))) fail('E_OWNER','untracked/ignored file outside knowledge');
|
|
213
|
-
if(checkModes) {
|
|
214
|
-
// Harvest judgments authorize content, not executable-bit edits. Inspect
|
|
215
|
-
// the filesystem against frozen objects, not core.fileMode or index flags.
|
|
216
|
-
for(const [p,entry] of gitTreeEntries(dest,baseline)) {
|
|
217
|
-
if(!['100644','100755'].includes(entry.mode)) continue;
|
|
218
|
-
const path=join(dest,p);
|
|
219
|
-
let stat;try {stat=fs.lstatSync(path);} catch(e) {if(e.code==='ENOENT' || e.code==='ENOTDIR') continue;throw e;}
|
|
220
|
-
if(stat.isFile() && (stat.mode & 0o100 ? '100755':'100644')!==entry.mode) fail('E_OWNER',`harvest cannot change Git file mode: ${p}`);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
verifyRemote(base,dest);
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
// Caller MUST hold the cooperative base lock. Intent is durable before journal
|
|
227
|
-
// installation; publishing is durable AFTER it and BEFORE the first accepted
|
|
228
|
-
// write. The journal is removed only after a durable accepted receipt. Thus an
|
|
229
|
-
// absent journal can cancel an intent, but never a write-authorized publication.
|
|
230
|
-
export function reconcileDirectoryIntent(base, receipt, persist) {
|
|
231
|
-
if(fs.existsSync(journalPath(base))) return;
|
|
232
|
-
if(receipt.status==='publication-intent') {
|
|
233
|
-
receipt.status='validated'; persist();
|
|
234
|
-
} else if(!['validated','staged','accepted','no-change'].includes(receipt.status)) {
|
|
235
|
-
fail('E_RECOVERY','directory publication journal missing after writes were authorized; inspect and restore custody before retrying or rejudging');
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
export function directoryPublish(base, proposal, receipt, persist, { afterWrite } = {}) {
|
|
239
|
-
noGit(base.path);
|
|
240
|
-
return withLock(baseLock(base),()=>{
|
|
241
|
-
const jp=journalPath(base);
|
|
242
|
-
reconcileDirectoryIntent(base,receipt,persist);
|
|
243
|
-
if(receipt.status==='accepted' && !fs.existsSync(jp)) return receipt;
|
|
244
|
-
const changed=[...new Set([...Object.keys(proposal.before),...Object.keys(proposal.after)])].filter(p=>proposal.before[p]!==proposal.after[p]);
|
|
245
|
-
// A mount boundary cannot support our cross-directory atomic rename.
|
|
246
|
-
// Detect it BEFORE journal intent or any deletion/replacement, rather than
|
|
247
|
-
// leaving a partially applied publication that can only fail with EXDEV.
|
|
248
|
-
const device=fs.statSync(baseLock(base)).dev;
|
|
249
|
-
for(const p of changed.filter(p=>proposal.after[p]!==undefined)) {
|
|
250
|
-
let parent=dirname(safePath(join(base.path,relPath(p))));
|
|
251
|
-
while(!fs.existsSync(parent)) parent=dirname(parent);
|
|
252
|
-
if(fs.statSync(parent).dev!==device) fail('E_PATH','directory publication targets and sibling lock must share a filesystem');
|
|
253
|
-
}
|
|
254
|
-
let journal;
|
|
255
|
-
if(fs.existsSync(jp)) {
|
|
256
|
-
journal=readJSON(jp);
|
|
257
|
-
if(journal.run!==proposal.run || journal.proposalHash!==hash(proposal)) fail('E_RECOVERY',`another publication must recover first: ${jp}`);
|
|
258
|
-
} else {
|
|
259
|
-
const current=validateBase(base.path,base);
|
|
260
|
-
if(current.digest!==digest(proposal.before)) fail('E_BASELINE','directory base changed; retain evidence and rejudge on a fresh run');
|
|
261
|
-
journal={version:1,run:proposal.run,proposalHash:hash(proposal),proposalFile:proposal.file,status:'publishing'};
|
|
262
|
-
receipt.status='publication-intent'; persist();
|
|
263
|
-
atomic(jp,JSON.stringify(journal,null,2)+'\n',{tempDir:baseLock(base)});
|
|
264
|
-
}
|
|
265
|
-
if(receipt.status==='accepted') {
|
|
266
|
-
// A death after receipt persistence needs only journal cleanup, never
|
|
267
|
-
// another accepted write (nor a replay over unexpected newer bytes).
|
|
268
|
-
const final=validateBase(base.path,base);
|
|
269
|
-
if(final.digest!==digest(proposal.after) || receipt.acceptedDigest!==final.digest) fail('E_CONFIRM','accepted directory receipt differs from journalled bytes');
|
|
270
|
-
fs.rmSync(jp); syncParent(jp); return receipt;
|
|
271
|
-
}
|
|
272
|
-
const current=tree(base.path);
|
|
273
|
-
for(const p of new Set([...Object.keys(current),...Object.keys(proposal.before),...Object.keys(proposal.after)])) {
|
|
274
|
-
if(current[p]!==proposal.before[p] && current[p]!==proposal.after[p]) fail('E_BASELINE',`unexpected bytes during publication recovery: ${p}`);
|
|
275
|
-
if(!changed.includes(p) && current[p]!==proposal.before[p]) fail('E_BASELINE',`unchanged file differs: ${p}`);
|
|
276
|
-
}
|
|
277
|
-
receipt.status='publishing'; persist();
|
|
278
|
-
let count=0;
|
|
279
|
-
for(const p of changed) {
|
|
280
|
-
const target=safePath(join(base.path,relPath(p)));
|
|
281
|
-
// A previous death may follow rename/unlink but precede its directory
|
|
282
|
-
// fsync. Confirm that metadata without replacing accepted bytes again.
|
|
283
|
-
if(current[p]===proposal.after[p]) {syncParent(target);continue;}
|
|
284
|
-
if(proposal.after[p]===undefined) { fs.rmSync(target,{force:true}); syncParent(target); }
|
|
285
|
-
// The lock owns scratch files as well as owner.json. A dead-owner unlock
|
|
286
|
-
// removes incomplete scratch; it never leaves temp files in the bundle.
|
|
287
|
-
else atomic(target,Buffer.from(proposal.after[p],'base64'),{tempDir:baseLock(base)});
|
|
288
|
-
afterWrite?.(++count); // fault injection is programmatic tests only, never an environment switch
|
|
289
|
-
}
|
|
290
|
-
const final=validateBase(base.path,base);
|
|
291
|
-
if(final.digest!==digest(proposal.after)) fail('E_CONFIRM','directory publication confirmation differs');
|
|
292
|
-
receipt.status='accepted'; receipt.acceptedDigest=final.digest; receipt.acceptedAt=new Date().toISOString(); persist();
|
|
293
|
-
fs.rmSync(jp); syncParent(jp);
|
|
294
|
-
return receipt;
|
|
295
|
-
},{waitMs:CONSULT_READER_WAIT_MS});
|
|
296
|
-
}
|
|
297
|
-
function syncParent(p) { const fd=fs.openSync(dirname(p),'r'); try {fs.fsyncSync(fd);} finally {fs.closeSync(fd);} }
|
|
298
|
-
function prRows(base,branch,cwd,{identity,allBases=false}={}) {
|
|
299
|
-
const fields='number,url,state,headRefOid,baseRefName,headRefName,mergedAt,mergeCommit';
|
|
300
|
-
// Once observed, a PR is addressed by repository + number, never a mutable
|
|
301
|
-
// head/base filter. A missing/inaccessible identity is an error, not absence.
|
|
302
|
-
if(identity) {
|
|
303
|
-
if(!Number.isInteger(identity.number) || identity.number<1 || !/^https:\/\//.test(identity.url)) fail('E_RECOVERY','invalid recorded PR identity');
|
|
304
|
-
const pr=JSON.parse(exec('gh',['pr','view',String(identity.number),'--repo',base.pr.repository,'--json',fields],{cwd,env:gitEnv()}));
|
|
305
|
-
if(!pr || Array.isArray(pr) || typeof pr!=='object') fail('E_RECOVERY','known PR missing; reconcile custody before rejudging');
|
|
306
|
-
return [pr];
|
|
307
|
-
}
|
|
308
|
-
const raw=exec('gh',['pr','list','--repo',base.pr.repository,'--head',branch,...(allBases?[]:['--base',base.acceptedBranch]),'--state','all','--json',fields],{cwd,env:gitEnv()});
|
|
309
|
-
const rows=JSON.parse(raw); if(!Array.isArray(rows)) fail('E_PR','invalid gh PR list'); return rows;
|
|
310
|
-
}
|
|
311
|
-
export function verifyRemote(base,cwd) {
|
|
312
|
-
for(const mode of [[],['--push']]) {
|
|
313
|
-
const urls=git(cwd,['remote','get-url',...mode,'--all','origin']).split('\n');
|
|
314
|
-
if(!urls.length || urls.some(url=>url!==base.repository)) fail('E_OWNER','worker changed frozen Git publication remote or effective push destination');
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
function changedPaths(before,after) {
|
|
318
|
-
return [...new Set([...Object.keys(before),...Object.keys(after)])].filter(p=>before[p]!==after[p]);
|
|
319
|
-
}
|
|
320
|
-
function gitTreeEntries(cwd,treeId) {
|
|
321
|
-
return new Map(git(cwd,['ls-tree','-r','-z',treeId]).split('\0').filter(Boolean).map(entry=>{
|
|
322
|
-
const tab=entry.indexOf('\t'),[mode,type,oid]=entry.slice(0,tab).split(' ');
|
|
323
|
-
return [entry.slice(tab+1),{mode,type,oid}];
|
|
324
|
-
}));
|
|
325
|
-
}
|
|
326
|
-
// Verify the REAL tree, not Git's working-tree view. Blob hashes include the
|
|
327
|
-
// exact bytes (no text trimming); Git filters/attributes and ignores cannot
|
|
328
|
-
// substitute something different from the validated proposal. Modes come only
|
|
329
|
-
// from the frozen baseline (new files are non-executable), never worker state.
|
|
330
|
-
function verifyPublicationTree(base,cwd,baseline,treeId,after,before=after) {
|
|
331
|
-
const names=git(cwd,['diff-tree','--no-commit-id','--no-renames','--name-only','-r','-z',baseline,treeId,'--']).split('\0').filter(Boolean);
|
|
332
|
-
if(base.root!=='.' && names.some(p=>!p.startsWith(base.root+'/'))) fail('E_OWNER','publication tree changes files outside its knowledge base');
|
|
333
|
-
const format=git(cwd,['rev-parse','--show-object-format']);
|
|
334
|
-
const prefix=base.root==='.'?'':base.root+'/';
|
|
335
|
-
const changed=new Set(changedPaths(before,after).map(p=>prefix+p));
|
|
336
|
-
if(names.some(p=>!changed.has(p))) fail('E_OWNER','publication tree changes paths outside the validated content changes');
|
|
337
|
-
const baselineEntries=gitTreeEntries(cwd,baseline);
|
|
338
|
-
const found=new Set();
|
|
339
|
-
for(const [path,{mode,type,oid}] of gitTreeEntries(cwd,treeId)) {
|
|
340
|
-
if(!path.startsWith(prefix)) continue;
|
|
341
|
-
const p=path.slice(prefix.length);
|
|
342
|
-
if(!Object.hasOwn(after,p) || type!=='blob' || !['100644','100755'].includes(mode)) fail('E_CONFIRM',`publication tree differs from validated proposal: ${p}`);
|
|
343
|
-
if(mode!==(baselineEntries.get(path)?.mode || '100644')) fail('E_OWNER',`publication tree changes frozen Git file mode: ${p}`);
|
|
344
|
-
const bytes=Buffer.from(after[p],'base64');
|
|
345
|
-
const expected=createHash(format).update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
346
|
-
if(oid!==expected) fail('E_CONFIRM',`publication tree bytes differ from validated proposal: ${p}`);
|
|
347
|
-
found.add(p);
|
|
348
|
-
}
|
|
349
|
-
if(found.size!==Object.keys(after).length) fail('E_CONFIRM','publication tree omits validated proposal files (check Git ignores/attributes)');
|
|
350
|
-
}
|
|
351
|
-
function immutableCommit(cwd,oid) {
|
|
352
|
-
if(typeof oid!=='string' || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oid)) fail('E_CONFIRM','publication requires an immutable full commit OID');
|
|
353
|
-
if(git(cwd,['cat-file','-t',oid])!=='commit') fail('E_CONFIRM','publication OID must identify a commit object, not a peelable tag');
|
|
354
|
-
// Read object headers, not revision history (which can honor grafts).
|
|
355
|
-
const header=git(cwd,['cat-file','commit',oid]).split('\n\n')[0].split('\n');
|
|
356
|
-
return {tree:header.find(l=>l.startsWith('tree '))?.slice(5),parents:header.filter(l=>l.startsWith('parent ')).map(l=>l.slice(7))};
|
|
357
|
-
}
|
|
358
|
-
export function gitPublish(base, stage, proposal, receipt, persist, {beforePublish=()=>{},prIdentity=receipt.pr,pr:presentation}={}) {
|
|
359
|
-
const cwd=stage.checkout, branch=`okf/${proposal.attempt || proposal.run}-${base.id}`;
|
|
360
|
-
verifyRemote(base,cwd);
|
|
361
|
-
const baseline=immutableCommit(cwd,stage.head);
|
|
362
|
-
verifyPublicationTree(base,cwd,baseline.tree,baseline.tree,proposal.before);
|
|
363
|
-
// Baseline must still be accepted. Never rebase model output without rejudging it.
|
|
364
|
-
git(cwd,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()});
|
|
365
|
-
const accepted=git(cwd,['rev-parse','FETCH_HEAD']);
|
|
366
|
-
if(accepted!==stage.head) {
|
|
367
|
-
// A known or uncertain previously created PR may be reconciled, but a
|
|
368
|
-
// committed/pushed proposal alone does not authorize a NEW stale-base PR.
|
|
369
|
-
const prior=receipt.commit?prRows(base,branch,cwd,{identity:prIdentity}).filter(p=>p.headRefOid===receipt.commit && p.headRefName===branch && p.baseRefName===base.acceptedBranch):[];
|
|
370
|
-
if(prior.length!==1) fail('E_BASELINE','accepted Git head changed before verified PR delivery; explicit rejudgment required');
|
|
371
|
-
}
|
|
372
|
-
if(!receipt.commit) {
|
|
373
|
-
// Publication consumes a frozen content proposal, also on migration/retry
|
|
374
|
-
// where materialization may have reset filesystem permissions. Its modes
|
|
375
|
-
// are reconstructed below, not authorized by the mutable worktree.
|
|
376
|
-
verifyGitScope(base,cwd,stage.head,{checkModes:false});
|
|
377
|
-
if(digest(tree(stage.root,{git:base.root==='.'}))!==digest(proposal.after)) fail('E_BASELINE','staged proposal changed after validation');
|
|
378
|
-
beforePublish();
|
|
379
|
-
receipt.status='commit-intent'; receipt.branch=branch; persist();
|
|
380
|
-
// Never trust the model's index. Start a private publication index from
|
|
381
|
-
// the frozen accepted commit, and leave the worker's own index intact.
|
|
382
|
-
const scratch=fs.mkdtempSync(join(dirname(proposal.file),'git-index-'));
|
|
383
|
-
let treeId;
|
|
384
|
-
try {
|
|
385
|
-
const env={...cleanEnv(),GIT_INDEX_FILE:join(scratch,'index')};
|
|
386
|
-
git(cwd,['read-tree',baseline.tree],{env});
|
|
387
|
-
const prefix=base.root==='.'?'':base.root+'/';
|
|
388
|
-
const entries=gitTreeEntries(cwd,baseline.tree);
|
|
389
|
-
// Recompute the delta from validated bytes, never trust proposal.changed
|
|
390
|
-
// or stage an entire directory (which also collects mode-only edits).
|
|
391
|
-
for(const p of changedPaths(proposal.before,proposal.after)) {
|
|
392
|
-
const path=prefix+relPath(p);
|
|
393
|
-
// An ignored addition can exit 1. The exact tree check below must still
|
|
394
|
-
// reject its omission; no ignore/filter failure becomes a partial PR.
|
|
395
|
-
git(cwd,['--literal-pathspecs','add','--all','--chmod=-x','--',path],{env,acceptedStatus:[0,1]});
|
|
396
|
-
if(Object.hasOwn(proposal.after,p) && entries.get(path)?.mode==='100755') git(cwd,['--literal-pathspecs','update-index','--chmod=+x','--',path],{env});
|
|
397
|
-
}
|
|
398
|
-
treeId=git(cwd,['write-tree'],{env});
|
|
399
|
-
verifyPublicationTree(base,cwd,baseline.tree,treeId,proposal.after,proposal.before);
|
|
400
|
-
} finally {fs.rmSync(scratch,{recursive:true,force:true});}
|
|
401
|
-
// Deterministic commit-tree permits replay after process death between the
|
|
402
|
-
// real Git write and receipt persistence; no guessed commit or fake repository.
|
|
403
|
-
const stamp=proposal.created;
|
|
404
|
-
const env={...cleanEnv(),GIT_AUTHOR_NAME:'OKF harvest',GIT_AUTHOR_EMAIL:'okf@localhost',GIT_COMMITTER_NAME:'OKF harvest',GIT_COMMITTER_EMAIL:'okf@localhost',GIT_AUTHOR_DATE:stamp,GIT_COMMITTER_DATE:stamp};
|
|
405
|
-
receipt.commit=git(cwd,['commit-tree',treeId,'-p',stage.head,'-m',`okf-harvest: ${proposal.run}`],{env});
|
|
406
|
-
receipt.status='committed'; persist();
|
|
407
|
-
}
|
|
408
|
-
const publication=immutableCommit(cwd,receipt.commit);
|
|
409
|
-
if(publication.parents.length!==1 || publication.parents[0]!==stage.head) fail('E_CONFIRM','publication commit must have exactly the frozen baseline as its parent');
|
|
410
|
-
verifyPublicationTree(base,cwd,baseline.tree,publication.tree,proposal.after,proposal.before);
|
|
411
|
-
const remoteTip=()=>git(cwd,['ls-remote','--heads','origin',`refs/heads/${branch}`],{timeout:gitTimeoutMs()}).split(/\s/)[0] || null;
|
|
412
|
-
let tip=remoteTip();
|
|
413
|
-
if(tip && tip!==receipt.commit) fail('E_PR','publication branch has unexpected commit; never force push');
|
|
414
|
-
if(tip!==receipt.commit) {
|
|
415
|
-
beforePublish();
|
|
416
|
-
receipt.status='push-intent'; persist();
|
|
417
|
-
try { verifyRemote(base,cwd);git(cwd,['push','--no-follow-tags','--recurse-submodules=no','origin',`${receipt.commit}:refs/heads/${branch}`],{timeout:gitTimeoutMs()}); }
|
|
418
|
-
catch(e) { receipt.status='push-unknown'; receipt.error=e.message; persist(); throw e; }
|
|
419
|
-
tip=remoteTip(); if(tip!==receipt.commit) {receipt.status='push-unknown';persist();fail('E_CONFIRM','pushed head not confirmed');}
|
|
420
|
-
}
|
|
421
|
-
receipt.status='pushed'; persist();
|
|
422
|
-
let rows;
|
|
423
|
-
try {rows=prRows(base,branch,cwd,{identity:prIdentity});} catch(e) {receipt.status='pr-unknown';receipt.error=e.message;persist();throw e;}
|
|
424
|
-
if(!rows.length) {
|
|
425
|
-
beforePublish();
|
|
426
|
-
receipt.status='pr-intent'; persist();
|
|
427
|
-
const title=presentation?.title || `okf-harvest: ${proposal.run}`,body=presentation?.body || `Knowledge-only proposal from durable OKF run ${proposal.run}. Review provenance and promotion judgment.`;
|
|
428
|
-
const label=presentation?.label;
|
|
429
|
-
// The label is what the harvest-review trigger watches: make sure the
|
|
430
|
-
// repository has it (idempotent), then open the PR carrying it.
|
|
431
|
-
if(label) try {exec('gh',['label','create',label,'--repo',base.pr.repository,'--force','--color','0E8A16','--description','OKF harvest PR (oats.okf)'],{cwd,env:gitEnv()});} catch { /* may exist already or be unmanageable; pr create reports a real problem */ }
|
|
432
|
-
try { exec('gh',['pr','create','--repo',base.pr.repository,'--head',branch,'--base',base.acceptedBranch,'--title',title,'--body',body,...(label?['--label',label]:[])],{cwd,env:gitEnv()}); }
|
|
433
|
-
catch(e) {receipt.status='pr-unknown';receipt.error=e.message;persist();throw e;}
|
|
434
|
-
rows=prRows(base,branch,cwd);
|
|
435
|
-
}
|
|
436
|
-
const matching=rows.filter(p=>p.headRefOid===receipt.commit && p.headRefName===branch && p.baseRefName===base.acceptedBranch && Number.isInteger(p.number) && /^https:\/\//.test(p.url) && (!prIdentity || (p.number===prIdentity.number && p.url===prIdentity.url)));
|
|
437
|
-
if(matching.length!==1 || rows.length!==1) {receipt.status='pr-unknown';persist();fail('E_PR','actual PR identity/head/base could not be uniquely verified');}
|
|
438
|
-
const pr=matching[0];receipt.pr=pr;receipt.status='delivered';receipt.deliveredAt ||= new Date().toISOString();persist();
|
|
439
|
-
if(pr.state==='CLOSED' && !pr.mergedAt) {receipt.status='rejected';persist();fail('E_PR','PR closed without merge; retained proposal requires operator review');}
|
|
440
|
-
if(pr.mergedAt) {
|
|
441
|
-
git(cwd,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()});
|
|
442
|
-
const merge=pr.mergeCommit?.oid;
|
|
443
|
-
if(!merge) fail('E_CONFIRM','merged PR lacks merge commit');
|
|
444
|
-
git(cwd,['merge-base','--is-ancestor',merge,'FETCH_HEAD']);
|
|
445
|
-
receipt.status='accepted';receipt.acceptedCommit=git(cwd,['rev-parse','FETCH_HEAD']);receipt.acceptedAt=new Date().toISOString();persist();
|
|
446
|
-
}
|
|
447
|
-
return receipt;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
export function recoveryStage(base, stage, proposal, dest) {
|
|
451
|
-
clone(base,dest,stage.head);
|
|
452
|
-
const root=join(dest,base.root);
|
|
453
|
-
for(const p of Object.keys(proposal.before)) if(!(p in proposal.after)) fs.rmSync(safePath(join(root,p)),{force:true});
|
|
454
|
-
materialize(root,proposal.after);validateBase(root,base);
|
|
455
|
-
return {...stage,root,checkout:dest};
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Remote-read-only recovery gate. --repo makes this independent of any deleted
|
|
459
|
-
// worker checkout. The caller durably saves first observations separately from
|
|
460
|
-
// historical receipts, even if a later gate prevents recovery from completing.
|
|
461
|
-
export function gitRecoveryState(base,receipt,cwd,{identity=receipt.pr,onObserve=()=>{}}={}) {
|
|
462
|
-
if(['accepted','no-change'].includes(receipt.status)) return 'settled';
|
|
463
|
-
if(!receipt.branch) return 'unresolved';
|
|
464
|
-
const rows=prRows(base,receipt.branch,cwd,{identity,allBases:true});
|
|
465
|
-
if(!rows.length) {
|
|
466
|
-
if(identity || ['delivered','rejected'].includes(receipt.status)) fail('E_RECOVERY','known PR missing; reconcile custody before rejudging');
|
|
467
|
-
return 'unresolved';
|
|
468
|
-
}
|
|
469
|
-
const pr=rows[0];
|
|
470
|
-
if(rows.length!==1 || pr.headRefOid!==receipt.commit || pr.headRefName!==receipt.branch || pr.baseRefName!==base.acceptedBranch || !Number.isInteger(pr.number) || pr.number<1 || !/^https:\/\//.test(pr.url) || [identity,receipt.pr].some(known=>known && (pr.number!==known.number || pr.url!==known.url)) || !['OPEN','CLOSED','MERGED'].includes(pr.state) || (pr.state==='MERGED' && !pr.mergedAt)) fail('E_RECOVERY','actual PR identity/head/base could not be uniquely verified for recovery');
|
|
471
|
-
onObserve(pr);
|
|
472
|
-
return pr.state==='CLOSED' && !pr.mergedAt?'unresolved':'settled';
|
|
473
|
-
}
|