@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
|
@@ -47,7 +47,8 @@ export function matchesPathPattern(pattern, rel) {
|
|
|
47
47
|
const normalizedRel = normalizeRelPath(rel)
|
|
48
48
|
if (!normalizedPattern || !normalizedRel) return false
|
|
49
49
|
const candidate = normalizedPattern.includes('/') ? normalizedRel : path.posix.basename(normalizedRel)
|
|
50
|
-
|
|
50
|
+
// dotAll: `**` crosses a newline in a name as well as a slash.
|
|
51
|
+
return new RegExp(`^${globSource(normalizedPattern)}$`, 'is').test(candidate)
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export const basenameOf = (pattern) => path.posix.basename(normalizeRelPath(pattern))
|
|
@@ -1,3 +1,59 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks'
|
|
2
|
+
|
|
3
|
+
// What the caller of cooperative work stopped it with: its `signal`, or its
|
|
4
|
+
// `guard` (the engine's check that the settings which selected a view still
|
|
5
|
+
// hold). Such an error is marked, so whatever catches it on the way out can
|
|
6
|
+
// tell it from a failure of the work itself and hand it on under its own code.
|
|
7
|
+
const SCHEDULING_STOP = Symbol('atelier.cooperative-stop')
|
|
8
|
+
const markedStop = (error) => {
|
|
9
|
+
if (error !== null && typeof error === 'object') { try { Object.defineProperty(error, SCHEDULING_STOP, { value: true }) } catch { /* a frozen error stays unmarked */ } }
|
|
10
|
+
return error
|
|
11
|
+
}
|
|
12
|
+
export const isSchedulingStop = (error) => error !== null && typeof error === 'object' && error[SCHEDULING_STOP] === true
|
|
13
|
+
|
|
14
|
+
// Internal scheduling shared by preparation and publication. The budget is a
|
|
15
|
+
// yield target, not a deadline: one checked unit and native fsync stay atomic.
|
|
16
|
+
export function createCooperativeBudget({ budgetMs = 8, maxUnits = 32, signal, guard = null, onBurst = null } = {}) {
|
|
17
|
+
if (!(Number.isFinite(budgetMs) && budgetMs > 0 && Number.isInteger(maxUnits) && maxUnits > 0)) throw new TypeError('finite positive scheduling budget required')
|
|
18
|
+
let started = performance.now(), units = 0, maxUnitMs = 0, last = started
|
|
19
|
+
const checkSignal = () => {
|
|
20
|
+
if (signal?.aborted) { const error = new Error('cooperative work cancelled at a completed boundary'); error.code = 'ABORT_ERR'; throw markedStop(error) }
|
|
21
|
+
}
|
|
22
|
+
const check = () => {
|
|
23
|
+
checkSignal()
|
|
24
|
+
if (guard) { try { guard() } catch (error) { throw markedStop(error) } }
|
|
25
|
+
}
|
|
26
|
+
const checkpoint = async (phase, force = false) => {
|
|
27
|
+
const now = performance.now()
|
|
28
|
+
maxUnitMs = Math.max(maxUnitMs, now - last); units += 1
|
|
29
|
+
checkSignal()
|
|
30
|
+
if (force || units >= maxUnits || now - started >= budgetMs) {
|
|
31
|
+
check()
|
|
32
|
+
onBurst?.({ phase, elapsedMs: now - started, units, maxUnitMs })
|
|
33
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
34
|
+
check()
|
|
35
|
+
started = performance.now(); units = 0; maxUnitMs = 0
|
|
36
|
+
}
|
|
37
|
+
last = performance.now()
|
|
38
|
+
}
|
|
39
|
+
const finish = phase => { const now = performance.now(); maxUnitMs = Math.max(maxUnitMs, now - last); onBurst?.({ phase, elapsedMs: now - started, units: units + 1, maxUnitMs }) }
|
|
40
|
+
return { checkpoint, check, finish }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function runCooperatively(iterator, options) {
|
|
44
|
+
const budget = createCooperativeBudget(options)
|
|
45
|
+
budget.check()
|
|
46
|
+
try {
|
|
47
|
+
for (;;) {
|
|
48
|
+
const step = iterator.next()
|
|
49
|
+
if (step.done) { budget.finish('prepare-finalize'); return step.value }
|
|
50
|
+
if (step.value === 'final-cache-commit') budget.check()
|
|
51
|
+
else await budget.checkpoint(step.value)
|
|
52
|
+
}
|
|
53
|
+
} catch (error) { iterator.return?.(); throw error }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
1
57
|
import { createHash } from 'node:crypto'
|
|
2
58
|
import { unclosedFenceAtEnd } from '../../../graph/knowledge-graph.mjs'
|
|
3
59
|
import {
|
|
@@ -638,14 +694,14 @@ function layoutOf({ layout, priorManifest, layoutHeld }) {
|
|
|
638
694
|
}
|
|
639
695
|
|
|
640
696
|
export function prepareView(options = {}) {
|
|
641
|
-
return
|
|
697
|
+
return drainPreparation(prepareSteps(REDACTION_RULES, options))
|
|
642
698
|
}
|
|
643
699
|
|
|
644
700
|
// Test seam only: the mutation controls of the redaction guard substitute a
|
|
645
701
|
// rule through this. Runtime code uses prepareView.
|
|
646
702
|
export function createViewPreparationForOracleTests(rules = REDACTION_RULES) {
|
|
647
703
|
const guard = { ...REDACTION_RULES, ...rules }
|
|
648
|
-
return (options = {}) =>
|
|
704
|
+
return (options = {}) => drainPreparation(prepareSteps(guard, options))
|
|
649
705
|
}
|
|
650
706
|
|
|
651
707
|
// `heldNotePaths` are the vault files held for an open edit: they stay in the
|
|
@@ -653,7 +709,7 @@ export function createViewPreparationForOracleTests(rules = REDACTION_RULES) {
|
|
|
653
709
|
// notes that hold a layout 1 view in layout 1 (open edits and edits closed on
|
|
654
710
|
// this tick); without it, `heldNotePaths` do. `viewScopeIds`, when given, are
|
|
655
711
|
// the views still maintained: the registry drops the sections of any other.
|
|
656
|
-
function
|
|
712
|
+
function* prepareSteps(guard, { snapshot, profile, scope, persistentPathRegistry = null, priorManifest = null, existingSettings = null, plugin = null, clock, generationId, vaultRootBytes, maxFullPathBytes, cache = null, heldNotePaths = null, layoutHeldNotePaths = null, viewScopeIds = null, layout: requestedLayout } = {}, recheckSources = false) {
|
|
657
713
|
if (cache !== null && !isPreparationCache(cache)) refuse('invalid-preparation-cache', 'the preparation cache must come from createPreparationCache')
|
|
658
714
|
assertObsidianContract('corpus-profile', profile)
|
|
659
715
|
assertObsidianContract('scope', scope)
|
|
@@ -670,6 +726,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
|
|
|
670
726
|
if (viewScopeIds !== null && !(Array.isArray(viewScopeIds) && viewScopeIds.every((id) => typeof id === 'string'))) refuse('invalid-view-scopes', 'viewScopeIds must be an array of scope identities')
|
|
671
727
|
const layout = layoutOf({ layout: requestedLayout, priorManifest, layoutHeld: layoutHeldNotePaths ?? heldNotePaths ?? [] })
|
|
672
728
|
const checkedAt = timestampFrom(clock)
|
|
729
|
+
yield 'prepare-validation'
|
|
673
730
|
const canonical = canonicalSnapshotOf(snapshot.graph)
|
|
674
731
|
|
|
675
732
|
// Selection and visibility come from the contract, never from this module.
|
|
@@ -686,6 +743,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
|
|
|
686
743
|
// Every view's allocation of the registry, this one's included; a registry of another shape or workspace refuses.
|
|
687
744
|
const views = viewsOfRegistry(persistentPathRegistry, profile.workspaceId)
|
|
688
745
|
|
|
746
|
+
yield 'selection-and-universe'
|
|
689
747
|
const edgeById = new Map(canonical.edges.map((edge) => [edge.id, edge]))
|
|
690
748
|
const vaultEdges = selection.vaultEdges.map((id) => edgeById.get(id)).filter((edge) => vault.has(edge.source) && vault.has(edge.target))
|
|
691
749
|
const outsideEdges = selection.outsideSelectionEdges.map((id) => edgeById.get(id)).filter((edge) => vault.has(edge.source) !== vault.has(edge.target))
|
|
@@ -714,6 +772,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
|
|
|
714
772
|
// taken. A view prepared in layout 1 holds layout 1 files, none of which is a
|
|
715
773
|
// layout 2 path. The registry keeps the result as this view's section,
|
|
716
774
|
// whichever layout the view is prepared in.
|
|
775
|
+
yield 'link-and-asset-index'
|
|
717
776
|
const readable = allocateViewPaths({
|
|
718
777
|
published: publishedOf(priorManifest),
|
|
719
778
|
nodes: [...vault].map((id) => nodeById.get(id)),
|
|
@@ -747,6 +806,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
|
|
|
747
806
|
return { key: { edgeKey: occurrence.target }, markdown: target.markdown, wikilink: target.wikilink, alias: true }
|
|
748
807
|
}
|
|
749
808
|
|
|
809
|
+
yield 'path-allocation'
|
|
750
810
|
const read = sourceReader(snapshot)
|
|
751
811
|
const orderedNodes = [...vault].map((id) => nodeById.get(id)).sort((left, right) => compare(left.repo, right.repo) || compare(left.id, right.id))
|
|
752
812
|
// Edges indexed by endpoint once, so a note's rows cost its degree and not
|
|
@@ -778,6 +838,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
|
|
|
778
838
|
const titles = titleRenderings()
|
|
779
839
|
|
|
780
840
|
for (const node of orderedNodes) {
|
|
841
|
+
yield 'emit-note'
|
|
781
842
|
const notePathValue = pathOf(node)
|
|
782
843
|
const attachmentPathValue = node.extension === 'md' ? null : attachmentOf(node)
|
|
783
844
|
const pinned = read.pinned(node.repo, node.path)
|
|
@@ -814,12 +875,14 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
|
|
|
814
875
|
// One copy per asset, however many notes embed it, read through the pinned
|
|
815
876
|
// snapshot like every other source.
|
|
816
877
|
for (const asset of embeddedAssets.values()) {
|
|
878
|
+
yield 'emit-asset'
|
|
817
879
|
const attachmentPath = assetPaths.get(asset.id)
|
|
818
880
|
const { bytes, rawDigest } = read(asset.repo, asset.path)
|
|
819
881
|
attachments.push({ path: attachmentPath, digest: rawDigest, byteLength: bytes.length, ext: { [EXT_KEY]: { kind: 'embedded-asset', repoId: asset.repo, assetPath: asset.path } } })
|
|
820
882
|
files.push({ path: attachmentPath, kind: 'attachment', bytes, digest: rawDigest })
|
|
821
883
|
}
|
|
822
884
|
|
|
885
|
+
yield 'emit-complete'
|
|
823
886
|
const links = vaultEdges
|
|
824
887
|
.map((edge) => {
|
|
825
888
|
const inversions = edge.type === DERIVED_RELATION_TYPE ? inversionsByEdge.get(`${edge.source}\u0000${edge.target}`) ?? [] : []
|
|
@@ -835,6 +898,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
|
|
|
835
898
|
})
|
|
836
899
|
.sort((left, right) => compare(left.edgeId, right.edgeId))
|
|
837
900
|
|
|
901
|
+
yield 'manifest-links'
|
|
838
902
|
const settings = prepareSettings({ existing: existingSettings, plugin })
|
|
839
903
|
files.push(...settings.files)
|
|
840
904
|
const occupied = new Set()
|
|
@@ -847,6 +911,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
|
|
|
847
911
|
files.sort((left, right) => compare(left.path, right.path))
|
|
848
912
|
attachments.sort((left, right) => compare(left.path, right.path))
|
|
849
913
|
|
|
914
|
+
yield 'settings-and-path-guard'
|
|
850
915
|
const contentDigest = createHash('sha256')
|
|
851
916
|
for (const file of files) contentDigest.update(`${file.path}\u0000${file.digest}\n`)
|
|
852
917
|
const manifest = {
|
|
@@ -877,6 +942,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
|
|
|
877
942
|
// the audience may see but this view does not select, every identifier is
|
|
878
943
|
// reported. An identifier this view's own notes share names nothing outside
|
|
879
944
|
// it.
|
|
945
|
+
yield 'manifest-validation'
|
|
880
946
|
const viewAttachments = new Set(attachments.map((item) => item.path))
|
|
881
947
|
const visible = new Set(universe.nodes)
|
|
882
948
|
const censusRepositories = new Set([...canonical.nodes, ...censusAssets].filter(usable).map((item) => item.repo))
|
|
@@ -927,6 +993,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
|
|
|
927
993
|
const allocatedInView = new Map(orderedNodes.map((node) => [node.id, pathOf(node)]))
|
|
928
994
|
const wrapperAttachments = new Set(orderedNodes.filter((node) => node.extension !== 'md').map((node) => (legacy ? `attachments/${legacyBasename(pathOf(node))}.${/^[a-z0-9]{1,16}$/.test(String(node.extension).toLowerCase()) ? String(node.extension).toLowerCase() : 'bin'}` : attachmentOf(node))))
|
|
929
995
|
const allowedAttachments = new Set([...wrapperAttachments, ...assetPaths.values()])
|
|
996
|
+
yield 'redaction-index'
|
|
930
997
|
const guardReported = assertViewRedaction({
|
|
931
998
|
manifest,
|
|
932
999
|
files,
|
|
@@ -954,6 +1021,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
|
|
|
954
1021
|
})
|
|
955
1022
|
}
|
|
956
1023
|
|
|
1024
|
+
yield 'redaction-validation'
|
|
957
1025
|
const prior = new Map((priorManifest?.notes ?? []).map((note) => [note.path, note.noteDigest]))
|
|
958
1026
|
const changes = { added: [], changed: [], unchanged: [], removed: [] }
|
|
959
1027
|
for (const note of notes) changes[!prior.has(note.path) ? 'added' : prior.get(note.path) === note.noteDigest ? 'unchanged' : 'changed'].push(note.path)
|
|
@@ -962,8 +1030,16 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
|
|
|
962
1030
|
// under the publication protocol.
|
|
963
1031
|
changes.removed = [...prior.keys()].filter((notePathValue) => !present.has(notePathValue)).sort(compare)
|
|
964
1032
|
|
|
965
|
-
//
|
|
966
|
-
//
|
|
1033
|
+
// A yield lets a source change after it was emitted (or reused). Check the
|
|
1034
|
+
// selected sources against the same pin again, before exposing the result
|
|
1035
|
+
// or replacing the derived cache. This does not create a new snapshot.
|
|
1036
|
+
if (recheckSources) {
|
|
1037
|
+
for (const node of orderedNodes) { yield 'recheck-source'; read(node.repo, node.path) }
|
|
1038
|
+
for (const asset of embeddedAssets.values()) { yield 'recheck-asset'; read(asset.repo, asset.path) }
|
|
1039
|
+
}
|
|
1040
|
+
// The last cancellation boundary precedes the cache commit. There is no
|
|
1041
|
+
// yield between that commit and returning its complete prepared result.
|
|
1042
|
+
yield 'final-cache-commit'
|
|
967
1043
|
if (nextCache) cache.notes = nextCache
|
|
968
1044
|
if (noteDiagnostics.length > 0) manifest.ext[EXT_KEY].diagnostics = noteDiagnostics
|
|
969
1045
|
|
|
@@ -995,3 +1071,9 @@ export function withEligibility(graph, isEligible, isAssetEligible = null) {
|
|
|
995
1071
|
: {}),
|
|
996
1072
|
}
|
|
997
1073
|
}
|
|
1074
|
+
|
|
1075
|
+
function drainPreparation(iterator) { for (;;) { const step = iterator.next(); if (step.done) return step.value } }
|
|
1076
|
+
// A wait lets a source change after it was emitted or reused, so the selected sources are read against their pins once
|
|
1077
|
+
// more before the result is returned. A caller that checks them itself, as the engine does by stat hint, turns that
|
|
1078
|
+
// second reading off with `scheduling.recheckSources: false`.
|
|
1079
|
+
export async function prepareViewCooperatively(options = {}) { return runCooperatively(prepareSteps(REDACTION_RULES, options, options.scheduling?.recheckSources !== false), options.scheduling) }
|
|
@@ -2,10 +2,10 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
|
|
5
|
-
import { PersonalWorkspaceRefusal, composePersonalWorkspace } from '@mnstry/atelier/personal-workspace'
|
|
5
|
+
import { PersonalWorkspaceRefusal, composePersonalWorkspace, readPersonalSelectionHead } from '@mnstry/atelier/personal-workspace'
|
|
6
6
|
import { ensureLocalState, resolveProjectConfig, validateProjectConfigDoc } from '@mnstry/atelier/project'
|
|
7
7
|
import { buildCanonicalGraph } from '../../graph/graph.mjs'
|
|
8
|
-
import { refuse } from '../../runtime/obsidian/errors.mjs'
|
|
8
|
+
import { ObsidianMaintenanceRefusal, refuse } from '../../runtime/obsidian/errors.mjs'
|
|
9
9
|
import { OBSIDIAN_EXT_KEY } from './contracts.mjs'
|
|
10
10
|
|
|
11
11
|
// The one personal-workspace route of the Obsidian projection.
|
|
@@ -60,6 +60,9 @@ const BINDING = Symbol('atelier.obsidian.personal-workspace-binding')
|
|
|
60
60
|
const ROUTES = new WeakMap()
|
|
61
61
|
// The last validity key the composition confirmed for each binding: { inputs, facts }.
|
|
62
62
|
const CONFIRMED = new WeakMap()
|
|
63
|
+
// The selection record a binding was loaded under, when its loader followed the person's confirmed selection instead of
|
|
64
|
+
// naming a generation itself (runtime/obsidian/personal-selection.mjs): { sequence, head, observe, recheck }.
|
|
65
|
+
const SELECTED = new WeakMap()
|
|
63
66
|
export const EVERYTHING_SCOPE_ID = 'everything'
|
|
64
67
|
const EXT_SCHEMA = 'atelier-obsidian-ext-settings/v1'
|
|
65
68
|
// The settings member of a bound project names its generation here. It survives any copy of the project (a structured
|
|
@@ -389,6 +392,12 @@ export async function validatePersonalWorkspace(project) {
|
|
|
389
392
|
if (binding === null) return null
|
|
390
393
|
const route = ROUTES.get(binding)
|
|
391
394
|
if (route === undefined) return { ok: false, code: 'personal-binding-unrecognized' }
|
|
395
|
+
// A project pinned to a confirmed selection holds only while the history still ends, intact, with that selection's
|
|
396
|
+
// record: what observation of the record cannot see (a record before it broken, a confirmation that landed before
|
|
397
|
+
// the engine first looked) is seen here.
|
|
398
|
+
if (SELECTED.get(binding)?.recheck) {
|
|
399
|
+
try { assertPersonalSelection(project) } catch (error) { if (!(error instanceof ObsidianMaintenanceRefusal)) throw error; CONFIRMED.delete(binding); return { ok: false, code: error.code } }
|
|
400
|
+
}
|
|
392
401
|
let composed
|
|
393
402
|
if (route.offThread) composed = await composeInWorker(binding, route.worker)
|
|
394
403
|
else {
|
|
@@ -400,12 +409,86 @@ export async function validatePersonalWorkspace(project) {
|
|
|
400
409
|
return { ok: true, changed: previous?.inputs !== composed.key.inputs || previous?.facts !== composed.key.facts }
|
|
401
410
|
}
|
|
402
411
|
|
|
412
|
+
const selectionRecord = ({ personalHome }, sequence) => path.join(personalHome, 'selections', `${String(sequence).padStart(6, '0')}.json`)
|
|
413
|
+
|
|
414
|
+
// The highest record number this process has read each home's selection history ending at, at any read of it: a
|
|
415
|
+
// loader's first read, its read-again, the recheck of a pinned project, and the read the engine's guard asks for when
|
|
416
|
+
// it stops a view (notePersonalSelectionNow). Kept in the main thread: no composition worker reads the history's end,
|
|
417
|
+
// and one that did would have a map of its own and escape the rule.
|
|
418
|
+
const READ_UP_TO = new Map()
|
|
419
|
+
|
|
420
|
+
// Notes where a home's selection history ends, as just read, and answers that head. Selections are only ever appended,
|
|
421
|
+
// so a history that ends at an earlier record than this process has read it ending at was cut short by something other
|
|
422
|
+
// than a confirmation. It refuses `selection-history-shortened` at every read, the read-again and the recheck of a
|
|
423
|
+
// project already loaded included, while it is shorter. Only the number is kept, and only in this process: a record in
|
|
424
|
+
// a place already read, with other content, is read as a confirmation, and a process started after the cut reads the
|
|
425
|
+
// history as it then stands. `refusesShortened: false` is a test seam only, the rule's mutation control.
|
|
426
|
+
export function notePersonalSelectionHead(personalHome, head, { refusesShortened = true } = {}) {
|
|
427
|
+
const before = READ_UP_TO.get(personalHome) ?? 0
|
|
428
|
+
if (head.sequence > before) READ_UP_TO.set(personalHome, head.sequence)
|
|
429
|
+
else if (refusesShortened && head.sequence < before) refuse('selection-history-shortened', 'the selection history of the personal workspace ends at an earlier record than this process has read it ending at; nothing is used from it while it is shorter', { source: 'personal-workspace' })
|
|
430
|
+
return head
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Pins a bound project to the selection record it was loaded under, and answers the project. From then on the engine
|
|
434
|
+
// observes that record and the place of the next one as configuration (personalWorkspaceInputs): a confirmation
|
|
435
|
+
// appended to the history, or a history cut short, is a change. And every time the composition is asked again
|
|
436
|
+
// (validatePersonalWorkspace) the history is read again and must still end with that record. `observe: false`,
|
|
437
|
+
// `recheck: false` and `refusesShortened: false` are test seams only, the mutation controls of each.
|
|
438
|
+
export function pinPersonalSelection(project, { sequence, head }, { observe = true, recheck = true, refusesShortened = true } = {}) {
|
|
439
|
+
SELECTED.set(personalWorkspaceBindingOf(project), Object.freeze({ sequence, head, observe, recheck, refusesShortened }))
|
|
440
|
+
return project
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// The selection a project is pinned to, as { personalHome, generationId, sequence, head }; null for any other project.
|
|
444
|
+
export function personalSelectionOf(project) {
|
|
445
|
+
const binding = personalWorkspaceBindingOf(project)
|
|
446
|
+
const pinned = binding === null ? undefined : SELECTED.get(binding)
|
|
447
|
+
return pinned === undefined ? null : { personalHome: binding.personalHome, generationId: binding.generationId, sequence: pinned.sequence, head: pinned.head }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Refuses `personal-selection-changed` unless the history still ends with the record this project is pinned to; a
|
|
451
|
+
// history that cannot be read refuses with the module's own code. What is read is noted first
|
|
452
|
+
// (notePersonalSelectionHead): a later record seen here counts from then on, and a history shorter than this process
|
|
453
|
+
// has read refuses `selection-history-shortened`, also when it ends with the pinned record. Nothing for a project that
|
|
454
|
+
// is not pinned.
|
|
455
|
+
export function assertPersonalSelection(project) {
|
|
456
|
+
const pinned = personalSelectionOf(project)
|
|
457
|
+
if (pinned === null) return
|
|
458
|
+
let now
|
|
459
|
+
try { now = readPersonalSelectionHead({ personalHome: pinned.personalHome }) } catch (error) { asMaintenanceRefusal(error, 'the selection history of the personal workspace could not be read; nothing is used from it') }
|
|
460
|
+
notePersonalSelectionHead(pinned.personalHome, now, SELECTED.get(personalWorkspaceBindingOf(project)))
|
|
461
|
+
if (now.sequence !== pinned.sequence || now.head !== pinned.head) refuse('personal-selection-changed', 'the confirmed selection of the personal workspace is no longer the one this project was loaded under; nothing is used from it', { source: 'personal-workspace' })
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Whether this process has read a pinned project's selection history ending at a later record than the one the
|
|
465
|
+
// project is pinned to: a confirmation since, or a history that was longer and is cut short now. Either way the project
|
|
466
|
+
// is not the one to publish under any more, and is loaded again: the load follows the confirmation, or refuses the
|
|
467
|
+
// shortened history. In memory, with no read. False for a project that is not pinned.
|
|
468
|
+
export function personalSelectionBehind(project) {
|
|
469
|
+
const pinned = personalSelectionOf(project)
|
|
470
|
+
return pinned !== null && (READ_UP_TO.get(pinned.personalHome) ?? 0) > pinned.sequence
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Reads where the history of a pinned project's home ends now, for the note alone: what the read refuses is not this
|
|
474
|
+
// caller's to raise. For the engine's guard, which sees a selection record's file change in the middle of a tick and
|
|
475
|
+
// stops the view as `mixed-read` without reading the history: the record that stopped it is read while it is there, so
|
|
476
|
+
// it counts when it is removed before the next tick. Nothing for a project that is not pinned.
|
|
477
|
+
export function notePersonalSelectionNow(project) {
|
|
478
|
+
try { assertPersonalSelection(project) } catch (error) { if (!(error instanceof ObsidianMaintenanceRefusal)) throw error }
|
|
479
|
+
}
|
|
480
|
+
|
|
403
481
|
// The authored and generation files whose change means the binding must be asked again: the manifest, the overlay and
|
|
404
|
-
// the bound generation's record
|
|
482
|
+
// the bound generation's record; and, for a project pinned to a selection, that selection's record and the place of the
|
|
483
|
+
// next one. Observed as configuration by the engine. Empty for any other project.
|
|
405
484
|
export function personalWorkspaceInputs(project) {
|
|
406
485
|
const binding = personalWorkspaceBindingOf(project)
|
|
407
486
|
if (binding === null) return []
|
|
408
|
-
|
|
487
|
+
const pinned = SELECTED.get(binding)
|
|
488
|
+
return [
|
|
489
|
+
path.join(binding.personalHome, 'atelier.personal.json'), path.join(binding.personalHome, 'atelier.overlay.json'), path.join(generationRoot(binding), 'generation.json'),
|
|
490
|
+
...(pinned?.observe ? [selectionRecord(binding, pinned.sequence), selectionRecord(binding, pinned.sequence + 1)] : []),
|
|
491
|
+
]
|
|
409
492
|
}
|
|
410
493
|
|
|
411
494
|
// The repository of a bound project's private notes, or null. A vault edit to one of them is never applied into a
|
|
@@ -15,6 +15,7 @@ import { isIdentifier } from '../edits/object-identity.mjs'
|
|
|
15
15
|
import { openObjectStore } from '../edits/object-store.mjs'
|
|
16
16
|
import { decideApply } from '../edits/policy.mjs'
|
|
17
17
|
import { applyEditLens } from '../edits/regions.mjs'
|
|
18
|
+
import { createCooperativeBudget } from '../materialize/prepare-view.mjs'
|
|
18
19
|
import { PERSONAL_VALIDATION_PENDING, personalOverlayRepoOf } from '../personal-workspace.mjs'
|
|
19
20
|
import { PublicationRefusal } from '../recovery/store.mjs'
|
|
20
21
|
import {
|
|
@@ -188,6 +189,9 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
|
|
|
188
189
|
// Set when an observation found the personal workspace's graph pending: the edits it could not look at are offered
|
|
189
190
|
// again on the next observation, not parked until a full reconciliation.
|
|
190
191
|
let observePendingAgain = false
|
|
192
|
+
// The observation under way, if any. An observation waits between two edits, and what it reads of this adapter
|
|
193
|
+
// (the flag above, the workspace it opens) is read when it begins: so the next one begins when this one has ended.
|
|
194
|
+
let observing = Promise.resolve()
|
|
191
195
|
|
|
192
196
|
// -- the workspace of one call -------------------------------------------
|
|
193
197
|
|
|
@@ -431,10 +435,13 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
|
|
|
431
435
|
|
|
432
436
|
// One repository: its lock, then each of its operations in turn. What goes wrong with one repository, or with one
|
|
433
437
|
// operation, is reported by its code and stops nothing else.
|
|
434
|
-
|
|
438
|
+
// `budget`, when a tick hands one over, gives the event loop a turn between two operations: each operation is
|
|
439
|
+
// settled without interruption, and the lock of the repository is held from the first to the last.
|
|
440
|
+
async function settleRepository(workspace, repoId, works, report, budget = null) {
|
|
435
441
|
try {
|
|
436
|
-
const held = await withRepositoryLock(workspace, repoId, () => {
|
|
437
|
-
for (const work of works) {
|
|
442
|
+
const held = await withRepositoryLock(workspace, repoId, async () => {
|
|
443
|
+
for (const [index, work] of works.entries()) {
|
|
444
|
+
if (budget !== null && index > 0) await budget.checkpoint('settle-operation', true)
|
|
438
445
|
try { report.outcomes.push(settleOne(workspace, work.item, work)) } catch (error) {
|
|
439
446
|
if (error?.crashSeam === true || !isTyped(error)) throw error
|
|
440
447
|
report.outcomes.push({ status: 'failed', code: error.code, editId: work.item.editId, repoId, nodeId: work.item.nodeId, adapterOperationId: work.item.adapterOperationId, state: null, proposalId: null })
|
|
@@ -448,6 +455,26 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
|
|
|
448
455
|
}
|
|
449
456
|
}
|
|
450
457
|
|
|
458
|
+
// One observation, from opening the workspace to the flag it leaves for the next.
|
|
459
|
+
async function observeNow(context, { retryRefused = false } = {}) {
|
|
460
|
+
const workspace = open(context)
|
|
461
|
+
const edits = Array.isArray(context.edits) ? context.edits : []
|
|
462
|
+
// A private note of a personal workspace lives in a generation the personal-workspace module owns: its edit is
|
|
463
|
+
// not observed, recorded or proposed, and stays held in the vault. Answered by name, with nothing written.
|
|
464
|
+
const overlayRepoId = personalOverlayRepoOf(workspace.project)
|
|
465
|
+
const isPrivate = (edit) => overlayRepoId !== null && edit?.identity?.repoId === overlayRepoId
|
|
466
|
+
const privateNotes = edits.filter((edit) => isPrivate(edit) && edit.closedAt === null)
|
|
467
|
+
.map((edit) => ({ editId: edit.editId, repoId: edit.identity.repoId, nodeId: edit.identity.nodeId, status: 'refused', code: 'personal-overlay-not-proposed' }))
|
|
468
|
+
const shared = edits.filter((edit) => !isPrivate(edit))
|
|
469
|
+
// The graph is built only when an edit is looked at, as for any project. When the personal workspace's graph is
|
|
470
|
+
// pending, the edits it reached are not reported or recorded, and are offered again on the next observation.
|
|
471
|
+
const retry = retryRefused || observePendingAgain
|
|
472
|
+
const looked = await observation.run.cooperatively(workspace, { edits: shared, retryRefused: retry })
|
|
473
|
+
const pending = looked.filter((item) => item.code === PERSONAL_VALIDATION_PENDING)
|
|
474
|
+
observePendingAgain = pending.length > 0
|
|
475
|
+
return { adapterId: PROPOSAL_ADAPTER_ID, observed: [...privateNotes, ...looked.filter((item) => item.code !== PERSONAL_VALIDATION_PENDING)], ...(pending.length > 0 ? { deferred: PERSONAL_VALIDATION_PENDING } : {}) }
|
|
476
|
+
}
|
|
477
|
+
|
|
451
478
|
return {
|
|
452
479
|
id: PROPOSAL_ADAPTER_ID,
|
|
453
480
|
|
|
@@ -455,23 +482,10 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
|
|
|
455
482
|
// observed from its preserved bytes against the source as it is now and recorded as what it is, a bounded number
|
|
456
483
|
// per tick and never twice. `retryRefused` offers once more the edits whose observation refused before anything
|
|
457
484
|
// could be recorded. Writes no source and no vault. Returns codes and identifiers only.
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
// not observed, recorded or proposed, and stays held in the vault. Answered by name, with nothing written.
|
|
463
|
-
const overlayRepoId = personalOverlayRepoOf(workspace.project)
|
|
464
|
-
const isPrivate = (edit) => overlayRepoId !== null && edit?.identity?.repoId === overlayRepoId
|
|
465
|
-
const privateNotes = edits.filter((edit) => isPrivate(edit) && edit.closedAt === null)
|
|
466
|
-
.map((edit) => ({ editId: edit.editId, repoId: edit.identity.repoId, nodeId: edit.identity.nodeId, status: 'refused', code: 'personal-overlay-not-proposed' }))
|
|
467
|
-
const shared = edits.filter((edit) => !isPrivate(edit))
|
|
468
|
-
// The graph is built only when an edit is looked at, as for any project. When the personal workspace's graph is
|
|
469
|
-
// pending, the edits it reached are not reported or recorded, and are offered again on the next observation.
|
|
470
|
-
const retry = retryRefused || observePendingAgain
|
|
471
|
-
const looked = observation.run(workspace, { edits: shared, retryRefused: retry })
|
|
472
|
-
const pending = looked.filter((item) => item.code === PERSONAL_VALIDATION_PENDING)
|
|
473
|
-
observePendingAgain = pending.length > 0
|
|
474
|
-
return { adapterId: PROPOSAL_ADAPTER_ID, observed: [...privateNotes, ...looked.filter((item) => item.code !== PERSONAL_VALIDATION_PENDING)], ...(pending.length > 0 ? { deferred: PERSONAL_VALIDATION_PENDING } : {}) }
|
|
485
|
+
observe(context, options = {}) {
|
|
486
|
+
const run = observing.then(() => observeNow(context, options))
|
|
487
|
+
observing = run.catch(() => {})
|
|
488
|
+
return run
|
|
475
489
|
},
|
|
476
490
|
|
|
477
491
|
// The tick. `context` is { project, workspaceRoot, workspaceId, repositoryRoots, edits }: the pending edit
|
|
@@ -481,6 +495,9 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
|
|
|
481
495
|
const nowMs = Date.parse(isoTime(clock))
|
|
482
496
|
const report = { adapterId: PROPOSAL_ADAPTER_ID, examined: 0, outcomes: [], repositories: [] }
|
|
483
497
|
const edits = Array.isArray(context.edits) ? context.edits : []
|
|
498
|
+
// The tick goes on answering while it routes: a turn of the event loop when describing has taken its share
|
|
499
|
+
// of time, and one before each repository and between two of its operations.
|
|
500
|
+
const budget = createCooperativeBudget()
|
|
484
501
|
const { operations: heads, unreadable } = workspace.queue().heads()
|
|
485
502
|
for (const item of unreadable) report.repositories.push({ repoId: null, code: item.code })
|
|
486
503
|
const byEdit = new Map(edits.map((edit) => [edit.editId, edit]))
|
|
@@ -497,7 +514,8 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
|
|
|
497
514
|
const editState = edit === null || edit.closedAt === null ? 'open' : ['withdrawn', 'superseded'].includes(edit.state) ? edit.state : 'closed'
|
|
498
515
|
let item = itemOfHead(head, edit)
|
|
499
516
|
if (edit !== null && head.state !== 'submitted') {
|
|
500
|
-
try { item = { ...item, ...(describe(workspace, edit) ?? {}) } } catch (error) { if (!isTyped(error)) throw error; report.outcomes.push({ status: 'failed', code: error.code, editId: head.editId, repoId: head.repoId, nodeId: head.nodeId, adapterOperationId: head.adapterOperationId, state: head.state, proposalId: null }); continue }
|
|
517
|
+
try { item = { ...item, ...(describe(workspace, edit) ?? {}) } } catch (error) { if (!isTyped(error)) throw error; report.outcomes.push({ status: 'failed', code: error.code, editId: head.editId, repoId: head.repoId, nodeId: head.nodeId, adapterOperationId: head.adapterOperationId, state: head.state, proposalId: null }); await budget.checkpoint('describe-operation'); continue }
|
|
518
|
+
await budget.checkpoint('describe-operation')
|
|
501
519
|
}
|
|
502
520
|
add(head.repoId, { item, editState })
|
|
503
521
|
}
|
|
@@ -510,11 +528,16 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
|
|
|
510
528
|
for (const edit of looked) {
|
|
511
529
|
report.examined += 1
|
|
512
530
|
let item
|
|
513
|
-
try { item = describe(workspace, edit) } catch (error) { if (!isTyped(error)) throw error; report.outcomes.push({ status: 'failed', code: error.code, editId: edit.editId, repoId: edit.identity.repoId, nodeId: edit.identity.nodeId, adapterOperationId: null, state: null, proposalId: null }); continue }
|
|
531
|
+
try { item = describe(workspace, edit) } catch (error) { if (!isTyped(error)) throw error; report.outcomes.push({ status: 'failed', code: error.code, editId: edit.editId, repoId: edit.identity.repoId, nodeId: edit.identity.nodeId, adapterOperationId: null, state: null, proposalId: null }); await budget.checkpoint('describe-edit'); continue }
|
|
514
532
|
if (item !== null) add(item.repoId, { item, editState: 'open' })
|
|
533
|
+
await budget.checkpoint('describe-edit')
|
|
515
534
|
}
|
|
516
535
|
|
|
517
|
-
for (const [repoId, list] of [...works].sort(([left], [right]) => compareText(left, right)))
|
|
536
|
+
for (const [repoId, list] of [...works].sort(([left], [right]) => compareText(left, right))) {
|
|
537
|
+
await budget.checkpoint('settle-repository', true)
|
|
538
|
+
await settleRepository(workspace, repoId, list, report, budget)
|
|
539
|
+
}
|
|
540
|
+
budget.finish('propose-finalize')
|
|
518
541
|
return report
|
|
519
542
|
},
|
|
520
543
|
|
|
@@ -10,6 +10,7 @@ import { SOURCE_APPLY_PRIMITIVES, SourceApplyRefusal, locateSource } from '../ed
|
|
|
10
10
|
import { EditArbitrationRefusal } from '../edits/arbitrate.mjs'
|
|
11
11
|
import { createEditObserverForOracleTests, editIdempotencyKey, observeEdit } from '../edits/observe.mjs'
|
|
12
12
|
import { decideApply } from '../edits/policy.mjs'
|
|
13
|
+
import { createCooperativeBudget } from '../materialize/prepare-view.mjs'
|
|
13
14
|
import { PublicationRefusal, readFileBytes, sha256Digest } from '../recovery/store.mjs'
|
|
14
15
|
import { PROPOSAL_BACKPRESSURE } from './backpressure.mjs'
|
|
15
16
|
|
|
@@ -38,6 +39,14 @@ import { PROPOSAL_BACKPRESSURE } from './backpressure.mjs'
|
|
|
38
39
|
// reconciliation cadence, never on every tick. A bounded number of edits is
|
|
39
40
|
// looked at per tick, in the order they were observed, starting where the last
|
|
40
41
|
// tick stopped.
|
|
42
|
+
//
|
|
43
|
+
// One edit is one unit: it is read, judged and recorded without interruption.
|
|
44
|
+
// Between two edits the cooperative form (`cooperatively`, which the adapter
|
|
45
|
+
// uses on a tick) gives the event loop a turn, so the process that runs the
|
|
46
|
+
// tick goes on answering while a burst of edits is observed. A second
|
|
47
|
+
// cooperative observation waits for the first to finish; what is looked at,
|
|
48
|
+
// and in what order, is the same in both forms. The uninterrupted form
|
|
49
|
+
// cannot wait, so it refuses to run beside a cooperative one.
|
|
41
50
|
|
|
42
51
|
const EXT = OBSIDIAN_EXT_KEY
|
|
43
52
|
const GONE = new Set(['ENOENT', 'ENOTDIR', 'ELOOP'])
|
|
@@ -203,7 +212,8 @@ export function createTickObservation(options = {}) {
|
|
|
203
212
|
// `workspace` is the opened workspace of the adapter; `edits` the pending edit records, open and closed;
|
|
204
213
|
// `retryRefused` offers the edits whose observation refused earlier once more. Returns one entry per edit looked
|
|
205
214
|
// at: { editId, repoId, nodeId, status: 'observed' | 'refused', code, kind?, operationState?, idempotencyKey? }.
|
|
206
|
-
|
|
215
|
+
// Yields between two edits, never within one, and returns the entries when it is done.
|
|
216
|
+
function* observeSteps(workspace, { edits, retryRefused = false }) {
|
|
207
217
|
if (retryRefused) unobservable.clear()
|
|
208
218
|
const now = isoTime(workspace.clock)
|
|
209
219
|
const candidates = edits.filter((edit) => edit.closedAt === null && OPEN_EDIT_STATES.includes(edit.state) && !recorded.has(edit.editId) && !unobservable.has(edit.editId))
|
|
@@ -212,7 +222,8 @@ export function createTickObservation(options = {}) {
|
|
|
212
222
|
const looked = [...candidates.slice(start), ...candidates.slice(0, start)].slice(0, bounds.maxObservedPerTick)
|
|
213
223
|
cursor = candidates.length > bounds.maxObservedPerTick ? start + looked.length : 0
|
|
214
224
|
const observed = []
|
|
215
|
-
for (const edit of looked) {
|
|
225
|
+
for (const [index, edit] of looked.entries()) {
|
|
226
|
+
if (index > 0) yield 'observe-edit'
|
|
216
227
|
const entry = { editId: edit.editId, repoId: edit.identity.repoId, nodeId: edit.identity.nodeId }
|
|
217
228
|
try {
|
|
218
229
|
const outcome = observeOne(workspace, edit, now)
|
|
@@ -226,4 +237,31 @@ export function createTickObservation(options = {}) {
|
|
|
226
237
|
}
|
|
227
238
|
return observed
|
|
228
239
|
}
|
|
240
|
+
|
|
241
|
+
// Cooperative observations asked for and not yet finished, and the last of them.
|
|
242
|
+
let underWay = 0
|
|
243
|
+
let last = Promise.resolve()
|
|
244
|
+
function observePendingEdits(workspace, input) {
|
|
245
|
+
if (underWay > 0) throw Object.assign(new Error('a cooperative observation of this instance is under way; the uninterrupted form cannot run beside it'), { code: 'observation-under-way' })
|
|
246
|
+
const steps = observeSteps(workspace, input)
|
|
247
|
+
for (;;) { const step = steps.next(); if (step.done) return step.value }
|
|
248
|
+
}
|
|
249
|
+
// The same observation with a turn of the event loop between two edits. One at a time: an observation asked for
|
|
250
|
+
// while another is under way starts when that one has finished, so no edit is looked at by both. One that failed
|
|
251
|
+
// holds back none that follow.
|
|
252
|
+
observePendingEdits.cooperatively = (workspace, input) => {
|
|
253
|
+
underWay += 1
|
|
254
|
+
const run = last.then(async () => {
|
|
255
|
+
const budget = createCooperativeBudget()
|
|
256
|
+
const steps = observeSteps(workspace, input)
|
|
257
|
+
for (;;) {
|
|
258
|
+
const step = steps.next()
|
|
259
|
+
if (step.done) { budget.finish('observe-finalize'); return step.value }
|
|
260
|
+
await budget.checkpoint(step.value, true)
|
|
261
|
+
}
|
|
262
|
+
}).finally(() => { underWay -= 1 })
|
|
263
|
+
last = run.catch(() => {})
|
|
264
|
+
return run
|
|
265
|
+
}
|
|
266
|
+
return observePendingEdits
|
|
229
267
|
}
|