@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.14
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/CHANGELOG.md +233 -0
- package/README.md +22 -5
- package/docs/blocks/capability-stewardship.md +1 -1
- package/docs/blocks/learning-harnesses.md +1 -1
- package/docs/capability-stewardship.md +2 -4
- package/docs/ingestion.md +3 -3
- package/docs/inquiry-harnesses.md +2 -3
- package/docs/install.md +3 -3
- package/docs/learning-harnesses.md +2 -3
- package/docs/local-services.md +42 -3
- package/docs/obsidian-contract.md +23 -0
- package/docs/obsidian-plugin.md +214 -3
- package/docs/obsidian.md +271 -11
- package/docs/personal-workspace.md +13 -4
- package/docs/release-engineering.md +2 -1
- package/docs/repo-boundary-guard.md +26 -3
- package/docs/semantic-operation.md +4 -3
- package/docs/upgrade.md +174 -2
- package/fixtures/templates/local-library.v1.json +2 -1
- package/package.json +2 -1
- package/plugins/obsidian/main.js +57 -8
- package/plugins/obsidian/manifest.json +1 -1
- package/src/boundary/content-rules.mjs +532 -67
- package/src/boundary/policy.mjs +135 -56
- package/src/cli/run.mjs +3 -2
- package/src/commands/obsidian.mjs +23 -7
- package/src/graph/graph.mjs +13 -5
- package/src/graph/knowledge-graph.mjs +19 -1
- package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
- package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
- package/src/ingestion/optional/graphify/native-pins.json +306 -0
- package/src/knowledge-health/rule-identity.mjs +14 -9
- package/src/personal-workspace/index.mjs +1 -1
- package/src/personal-workspace/selection.mjs +17 -1
- package/src/project/path-match.mjs +2 -1
- package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
- package/src/projection/obsidian/personal-workspace.mjs +87 -4
- package/src/projection/obsidian/proposals/adapter.mjs +46 -23
- package/src/projection/obsidian/proposals/observation.mjs +40 -2
- package/src/projection/obsidian/publication/publisher.mjs +72 -5
- package/src/projection/project.mjs +3 -2
- package/src/runtime/obsidian/engine.mjs +293 -55
- package/src/runtime/obsidian/lifecycle.mjs +17 -12
- package/src/runtime/obsidian/login-item.mjs +4 -1
- package/src/runtime/obsidian/observation.mjs +36 -4
- package/src/runtime/obsidian/opening.mjs +3 -2
- package/src/runtime/obsidian/personal-selection.mjs +125 -0
- package/src/runtime/obsidian/pipeline.mjs +44 -3
- package/src/runtime/obsidian/private-lock.mjs +39 -4
- package/src/runtime/obsidian/service-main.mjs +58 -12
- package/src/runtime/obsidian/service.mjs +66 -6
- package/src/runtime/obsidian/view-permission.mjs +334 -0
- package/src/ui/control-system.mjs +201 -0
- package/src/ui/html-primitives.mjs +3 -0
- package/src/upgrade/closure-diagnostics.mjs +135 -0
- package/src/upgrade/closure.mjs +649 -0
- package/src/upgrade/upgrade.mjs +19 -7
- package/templates/external-project-workspace/pack-lifecycle.json +2 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createCooperativeBudget, isSchedulingStop } from '../materialize/prepare-view.mjs'
|
|
1
2
|
import fs from 'node:fs'
|
|
2
3
|
import fsp from 'node:fs/promises'
|
|
3
4
|
import path from 'node:path'
|
|
@@ -317,11 +318,33 @@ export function planUnits({ files, priorManifest, pointer, ledger, pluginDisk =
|
|
|
317
318
|
return units
|
|
318
319
|
}
|
|
319
320
|
|
|
321
|
+
// The code of a failure met while candidates were being cleaned up after a staging that did not complete, for the detail
|
|
322
|
+
// of what ended it. Nothing else of the failure is kept: its message may name a path.
|
|
323
|
+
const cleanupCauseOf = (failure) => (failure === null ? {} : { cleanupCause: typeof failure?.code === 'string' && failure.code !== '' ? failure.code.slice(0, 64) : 'unknown' })
|
|
324
|
+
// The stop or refusal itself is handed on, so the caller can still tell it by identity: its `detail` is replaced by a
|
|
325
|
+
// copy that adds `cleanupCause`, and an object shared as its detail is left as it was. A caller that throws the same
|
|
326
|
+
// error object again keeps that field on it.
|
|
327
|
+
function withCleanupCause(error, failure) {
|
|
328
|
+
if (failure !== null) try { error.detail = { ...(error.detail ?? {}), ...cleanupCauseOf(failure) } } catch { /* an error that cannot be extended is handed on as it is */ }
|
|
329
|
+
return error
|
|
330
|
+
}
|
|
331
|
+
|
|
320
332
|
// The rules the oracle tests are sensitive to. Production always uses these; the tests substitute a deliberately
|
|
321
333
|
// broken one through publishViewForOracleTests to prove the oracle can fail.
|
|
322
334
|
export const PUBLICATION_PRIMITIVES = Object.freeze({
|
|
323
335
|
// The one operation the `direct-unheld` path writes into the vault: a file created where nothing is.
|
|
324
336
|
unheldWrites: (op) => op === 'create',
|
|
337
|
+
// Whether an error that stopped staging was the caller's stop (its settings check, or a cancellation), handed on
|
|
338
|
+
// under its own code once the candidates are cleaned up, rather than reported as a staging failure.
|
|
339
|
+
callerStoppedStaging: (error) => isSchedulingStop(error),
|
|
340
|
+
// Whether a refusal raised while candidates are being staged (the vault's folder replaced or gone under the run) is
|
|
341
|
+
// handed on only once those candidates are cleaned up, as a stop is. A candidate staged before the journal is
|
|
342
|
+
// written is named by no journal, so restart recovery would never remove one left there.
|
|
343
|
+
refusedStagingCleansUp: () => true,
|
|
344
|
+
// The code the journal's last entry gives an error that ended the publication once the journal was open: a
|
|
345
|
+
// caller's stop under its own code, which is what the caller is told, and anything else as a fault of the publisher.
|
|
346
|
+
// Decided by the tag a stop carries, never by what an error looks like; a stop that carries no code has none to give.
|
|
347
|
+
journalFailureCode: (error) => (isSchedulingStop(error) && typeof error.code === 'string' && error.code !== '' ? error.code.slice(0, 200) : 'publisher-error'),
|
|
325
348
|
})
|
|
326
349
|
|
|
327
350
|
// Whether the `direct-unheld` path applies, after the adapter found an app it cannot coordinate with (see the head of
|
|
@@ -355,6 +378,8 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
|
|
|
355
378
|
if (!adapter || typeof adapter.probe !== 'function') throw new TypeError('publishView needs an editor coordination adapter')
|
|
356
379
|
if (expectedGeneration !== null && typeof expectedGeneration !== 'string') throw new TypeError('expectedGeneration must be a generation identity or null')
|
|
357
380
|
|
|
381
|
+
const budget = createCooperativeBudget(options.scheduling)
|
|
382
|
+
budget.check()
|
|
358
383
|
const releases = []
|
|
359
384
|
let journal = null
|
|
360
385
|
try {
|
|
@@ -384,9 +409,16 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
|
|
|
384
409
|
store.checkAllocatedVault?.()
|
|
385
410
|
acquire(store.vaultLockPath, () => acquireVaultLock(store), 'into this vault')
|
|
386
411
|
store.checkAllocatedVault?.()
|
|
412
|
+
await budget.checkpoint('recover', true)
|
|
413
|
+
store.checkAllocatedVault?.()
|
|
387
414
|
const recovered = recoverPublicationsLocked({ store, clock })
|
|
415
|
+
await budget.checkpoint('recovery-complete', true)
|
|
416
|
+
store.checkAllocatedVault?.()
|
|
388
417
|
const pointer = store.readCurrent()
|
|
389
418
|
if (pointer?.generationId === manifest.generationId && !pluginFilesDrifted(preparedView, store)) {
|
|
419
|
+
await options.beforeCommit?.()
|
|
420
|
+
budget.check()
|
|
421
|
+
store.checkAllocatedVault?.()
|
|
390
422
|
return { state: 'committed', alreadyCommitted: true, generationId: manifest.generationId, journalId: pointer.journalId, notes: [], retainedEdits: pointer.retained ?? [], lateWriters: [], recovered }
|
|
391
423
|
}
|
|
392
424
|
if ((pointer?.generationId ?? null) !== expectedGeneration) refuse('generation-mismatch', 'the committed generation is not the one this publication expects', { committed: pointer?.generationId ?? null })
|
|
@@ -426,12 +458,16 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
|
|
|
426
458
|
stagingDir = store.stagingDir(journalId)
|
|
427
459
|
// On the `direct-unheld` path a replacement is never written, so no candidate is staged for one.
|
|
428
460
|
for (const unit of units.filter((item) => item.op === 'create' || (item.op === 'replace' && (mode !== 'direct-unheld' || rules.unheldWrites(item.op))))) {
|
|
461
|
+
await budget.checkpoint('stage-candidate')
|
|
462
|
+
store.checkAllocatedVault?.()
|
|
429
463
|
const existing = fs.lstatSync(path.join(store.vaultRoot, unit.path), { throwIfNoEntry: false })
|
|
430
464
|
unit.preparedPath = store.preparedPath(journalId, unit.unit)
|
|
431
465
|
stageCandidate(unit.preparedPath, unit.bytes, unit.mode ?? (existing?.isFile() ? existing.mode & 0o777 : 0o644), created)
|
|
432
466
|
// A replacement is exchanged, and the exchange leaves the displaced bytes at this path: it is in recovery.
|
|
433
467
|
unit.stagedPath = unit.op === 'replace' ? store.exchangeCandidatePath(journalId, unit.unit) : unit.preparedPath
|
|
434
468
|
}
|
|
469
|
+
await budget.checkpoint('staging-complete', true)
|
|
470
|
+
store.checkAllocatedVault?.()
|
|
435
471
|
syncPrivateDirectory(stagingDir)
|
|
436
472
|
const manifestFile = path.join(store.journalDir(journalId), 'manifest.json')
|
|
437
473
|
publishPrivateFile(manifestFile, manifestBytes)
|
|
@@ -446,22 +482,39 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
|
|
|
446
482
|
// The journal now names every exchange candidate by path and digest. Only complete, fsynced files are moved
|
|
447
483
|
// in, so a file at an exchange path is never a partial candidate.
|
|
448
484
|
for (const unit of units.filter((item) => item.stagedPath && item.stagedPath !== item.preparedPath)) {
|
|
485
|
+
await budget.checkpoint('move-candidate')
|
|
486
|
+
store.checkAllocatedVault?.()
|
|
449
487
|
moveToExchangePath(store, journalId, unit, moved)
|
|
450
488
|
}
|
|
451
489
|
} catch (error) {
|
|
452
|
-
|
|
490
|
+
const stopped = rules.callerStoppedStaging(error)
|
|
491
|
+
// A refusal raised between two candidates (the publisher's own check found the vault's folder replaced or gone)
|
|
492
|
+
// is handed on under its own code too, and like a stop only after the cleanup below.
|
|
493
|
+
const refused = error instanceof PublicationRefusal && !stopped
|
|
494
|
+
if (refused && !rules.refusedStagingCleansUp(error)) throw error
|
|
453
495
|
// No payload has named any of these paths, so nothing was exchanged and they can only hold our own
|
|
454
496
|
// candidates. In staging they are removed by name. In recovery a file is removed only when its bytes are
|
|
455
497
|
// the candidate's; anything else stays for restart recovery, which the journal header points at it. Only
|
|
456
498
|
// empty directories are removed, so a failed staging leaves nothing that looks like an unfinished unit.
|
|
499
|
+
// Each file is cleaned up on its own, so one that cannot be removed (EBUSY, EPERM, a volume gone) leaves the
|
|
500
|
+
// others removed; the first such failure is kept.
|
|
501
|
+
let cleanupFailure = null
|
|
502
|
+
const cleanUp = (operation) => { try { operation() } catch (failure) { cleanupFailure ??= failure } }
|
|
457
503
|
for (const unit of moved) {
|
|
458
|
-
if (readFileDigest(unit.stagedPath) === unit.candidateDigest) fs.rmSync(unit.stagedPath, { force: true })
|
|
504
|
+
cleanUp(() => { if (readFileDigest(unit.stagedPath) === unit.candidateDigest) fs.rmSync(unit.stagedPath, { force: true }) })
|
|
459
505
|
try { fs.rmdirSync(path.dirname(unit.stagedPath)) } catch { /* not empty: left for restart recovery */ }
|
|
460
506
|
}
|
|
461
|
-
for (const file of created) fs.rmSync(file, { force: true })
|
|
507
|
+
for (const file of created) cleanUp(() => fs.rmSync(file, { force: true }))
|
|
462
508
|
for (const directory of [stagingDir, store.unitsRoot(journalId)]) if (directory) try { fs.rmdirSync(directory) } catch { /* absent, or not empty */ }
|
|
463
|
-
|
|
509
|
+
// The caller stopped the publication between two candidates: nothing failed to stage, and its reason (a
|
|
510
|
+
// setting that changed during the tick, `mixed-read`, or a cancellation) is the one reported. So is a refusal's.
|
|
511
|
+
// A cleanup that failed does not replace either: it is named in their detail as `cleanupCause`, and what it could
|
|
512
|
+
// not remove stays where it is.
|
|
513
|
+
if (stopped || refused) throw withCleanupCause(error, cleanupFailure)
|
|
514
|
+
refuse('staging-failed', 'the candidates could not be staged; nothing in the vault was touched', { cause: error.code ?? String(error.message), ...cleanupCauseOf(cleanupFailure) })
|
|
464
515
|
}
|
|
516
|
+
await budget.checkpoint('journal-and-moves-complete', true)
|
|
517
|
+
store.checkAllocatedVault?.()
|
|
465
518
|
crash('after-staging')
|
|
466
519
|
|
|
467
520
|
const context = { store, journal, journalId, channel, clock, crash, mode, rules }
|
|
@@ -479,6 +532,8 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
|
|
|
479
532
|
// unit refuses, a kept one included, so nothing is committed.
|
|
480
533
|
let lastProbe = null
|
|
481
534
|
for (const unit of units) {
|
|
535
|
+
await budget.checkpoint('publish-unit')
|
|
536
|
+
store.checkAllocatedVault?.()
|
|
482
537
|
if (mode === 'direct' && (lastProbe === null || Date.now() - lastProbe > 2000)) {
|
|
483
538
|
lastProbe = Date.now()
|
|
484
539
|
const again = await adapter.probe({ vaultRoot: store.vaultRoot })
|
|
@@ -501,6 +556,8 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
|
|
|
501
556
|
}
|
|
502
557
|
}
|
|
503
558
|
|
|
559
|
+
await budget.checkpoint('publication-complete', true)
|
|
560
|
+
store.checkAllocatedVault?.()
|
|
504
561
|
const blocking = results.filter((result) => result.blocking)
|
|
505
562
|
const retainedEdits = results.filter((result) => result.retained).map((result) => result.retained)
|
|
506
563
|
// Notes kept earlier stay surfaced until they leave the vault or come back into a view.
|
|
@@ -517,6 +574,16 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
|
|
|
517
574
|
journal.append({ step: 'verify', outcome: 'conflict', state: 'verifying', notePath: finding.notePath, beforeDigest: finding.digestAtMove, afterDigest: finding.observedDigest, recoveryRef: finding.displacedRef,
|
|
518
575
|
detail: { unit: finding.unit, code: 'late-writer-captured', objectRef: finding.objectRef } })
|
|
519
576
|
}
|
|
577
|
+
await budget.checkpoint('verify-before-commit', true)
|
|
578
|
+
// The resident engine revalidates the selected source pins here, while
|
|
579
|
+
// publication still owns its locks. Its check may yield; no durability
|
|
580
|
+
// unit or committed pointer is half-written across that wait. It comes
|
|
581
|
+
// before the entry that says the publication settled: restart recovery
|
|
582
|
+
// commits a journal that holds that entry, so a refusal after it would be
|
|
583
|
+
// committed by the next publication of this view.
|
|
584
|
+
await options.beforeCommit?.()
|
|
585
|
+
budget.check()
|
|
586
|
+
store.checkAllocatedVault?.()
|
|
520
587
|
journal.append({ step: 'verify', outcome: 'ok', state: 'verifying', detail: { settled: true, retained: retainedEdits } })
|
|
521
588
|
crash('before-manifest-commit')
|
|
522
589
|
store.checkAllocatedVault?.()
|
|
@@ -528,7 +595,7 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
|
|
|
528
595
|
return { ...base, state: 'committed', lateWriters }
|
|
529
596
|
} catch (error) {
|
|
530
597
|
if (!(error instanceof PublicationRefusal)) {
|
|
531
|
-
if (journal) try { journal.append({ step: 'verify', outcome: 'failed', state: 'failed', detail: { code:
|
|
598
|
+
if (journal) try { journal.append({ step: 'verify', outcome: 'failed', state: 'failed', detail: { code: rules.journalFailureCode(error), message: String(error.code ?? error.message).slice(0, 200) } }) } catch { /* the journal itself cannot be written */ }
|
|
532
599
|
throw error
|
|
533
600
|
}
|
|
534
601
|
return { state: 'refused', refusal: { code: error.code, message: error.message, detail: error.detail }, notes: [], retainedEdits: [], lateWriters: [] }
|
|
@@ -3,6 +3,7 @@ import { isDeepStrictEqual } from 'node:util'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { commandProject, readJson, writeJson } from '../project/config.mjs'
|
|
5
5
|
import { summarizeReadinessJourney } from '../readiness-protocols/runtime.mjs'
|
|
6
|
+
import { atelierControlStyles } from '../ui/control-system.mjs'
|
|
6
7
|
|
|
7
8
|
const esc = (value) => String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
|
8
9
|
|
|
@@ -53,7 +54,7 @@ export function buildProjectProjection(project) {
|
|
|
53
54
|
<h3>${esc(item.title)}</h3>
|
|
54
55
|
<p><strong>${esc(item.status)}</strong> · ${esc(item.score)} / 100 answers complete</p>
|
|
55
56
|
<p>${esc(item.blockers[0] || 'review-ready')}</p>
|
|
56
|
-
<button type="button" data-copy="${esc(item.agentPrompt)}">Copy protocol prompt</button>
|
|
57
|
+
<button type="button" class="system-control" data-variant="secondary" data-size="compact" data-copy="${esc(item.agentPrompt)}">Copy protocol prompt</button>
|
|
57
58
|
</article>`).join('\n')
|
|
58
59
|
const cards = graph.nodes.map((node) => `<article class="card" data-node="${esc(node.id)}">
|
|
59
60
|
<p class="eyebrow">${esc(node.repo)} · ${esc(node.type)} · ${esc(node.audience)}</p>
|
|
@@ -80,9 +81,9 @@ h1{font-size:clamp(2rem,4vw,4rem);margin:.2em 0}
|
|
|
80
81
|
.readiness-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-top:16px}
|
|
81
82
|
.readiness-card{border:1px solid #342b26;border-radius:8px;padding:14px;background:#171312}
|
|
82
83
|
.readiness-card h3{font-size:1.05rem;margin:.2rem 0}
|
|
83
|
-
.readiness-card button{min-height:44px;border:1px solid #755f43;border-radius:6px;background:#241d18;color:var(--atelier-text);padding:8px 10px;cursor:pointer}
|
|
84
84
|
.eyebrow{color:var(--atelier-eyebrow);text-transform:uppercase;letter-spacing:.08em;font-size:.8rem}
|
|
85
85
|
a{color:var(--atelier-accent)}
|
|
86
|
+
${atelierControlStyles}
|
|
86
87
|
${branding.themeCss}</style>
|
|
87
88
|
</head>
|
|
88
89
|
<body><main>
|