@shardworks/spider-apparatus 0.1.224 → 0.1.226
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 +13 -0
- package/dist/spider.d.ts.map +1 -1
- package/dist/spider.js +255 -0
- package/dist/spider.js.map +1 -1
- package/dist/types.d.ts +52 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +9 -9
- package/src/static/spider-ui.test.ts +326 -0
- package/src/static/spider.js +605 -254
package/README.md
CHANGED
|
@@ -53,6 +53,19 @@ CrawlResult variants:
|
|
|
53
53
|
| `'engine-grafted'` | Engine injected additional engines into the pipeline |
|
|
54
54
|
| `'rig-completed'` | Rig reached terminal or stuck state (completed, stuck, failed, or cancelled) |
|
|
55
55
|
| `'rig-blocked'` | All forward progress stalled; rig entered blocked status |
|
|
56
|
+
| `'gated'` | Spawn was deferred — the writ has outbound `spider.follows` blockers (includes the case where a blocker reached `failed` and the writ was cascaded to `stuck`) |
|
|
57
|
+
| `'writ-unstuck'` | `autoUnstick` phase returned a previously-Spider-stuck writ to `open` because its recorded blockers resolved |
|
|
58
|
+
|
|
59
|
+
### Dispatch gating via `spider.follows`
|
|
60
|
+
|
|
61
|
+
Spider contributes a `spider.follows` link kind and consults outbound `spider.follows` links when deciding whether to spawn a rig for an open writ. The gate is evaluated in `trySpawn` before any rig is created:
|
|
62
|
+
|
|
63
|
+
- If any direct outbound `spider.follows` target is still `new`, `open`, or `stuck` — the writ is held. `trySpawn` emits a `'gated'` result and the writ stays `open` until a later poll.
|
|
64
|
+
- If any direct outbound target is `failed` — the writ is cascaded to `stuck`. A `status.spider` record is written with `stuckCause: 'failed-blocker'` and the ids of every failed blocker. The resolution text is `Blocked by failed dependency: <short-id>` (or `Blocked by failed dependencies: …` when plural).
|
|
65
|
+
- If the full transitive `spider.follows` walk from the writ visits a cycle — the writ is cascaded to `stuck` with `stuckCause: 'cycle'` and the cycle members as blockers. The resolution text is `Detected spider.follows cycle: <id> → <id> → … → <id>`.
|
|
66
|
+
- Only when every direct outbound target is in a terminal-success state (`completed`, or `cancelled`) does the writ proceed to rig spawn.
|
|
67
|
+
|
|
68
|
+
Before `trySpawn`, each crawl tick runs an `autoUnstick` pass that re-evaluates every writ whose `status.spider.stuckCause` is set by this plugin. When the recorded cause resolves (all `failed-blocker` ids are now `completed`/`cancelled`, or any `cycle` member has moved out of `open`/`stuck`), the writ is returned to `open` and emits a `'writ-unstuck'` result. Engine-cascade `stuck` writs (no `status.spider` slot present — the legacy path that writes only `resolution`) are left untouched.
|
|
56
69
|
|
|
57
70
|
### `show(id): Promise<RigDoc>`
|
|
58
71
|
|
package/dist/spider.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"spider.d.ts","sourceRoot":"","sources":["../src/spider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAwD,MAAM,wBAAwB,CAAC;AAQ3G,OAAO,KAAK,EACV,MAAM,EAUN,WAAW,
|
|
1
|
+
{"version":3,"file":"spider.d.ts","sourceRoot":"","sources":["../src/spider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAwD,MAAM,wBAAwB,CAAC;AAQ3G,OAAO,KAAK,EACV,MAAM,EAUN,WAAW,EAOZ,MAAM,YAAY,CAAC;AAmDpB,uEAAuE;AACvE,MAAM,WAAW,SAAS;IACxB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAC3C,oFAAoF;IACpF,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9C;AAyDD;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,CAQ1D;AAED;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM5D;AA2/BD,wBAAgB,YAAY,IAAI,MAAM,CAy0CrC"}
|
package/dist/spider.js
CHANGED
|
@@ -32,6 +32,15 @@ import { crawlOneTool, crawlContinualTool, rigShowTool, rigListTool, rigForWritT
|
|
|
32
32
|
import { spiderRoutes } from "./oculus-routes.js";
|
|
33
33
|
import { interpolateTemplate, extractExpressions, resolveDotPath, SKIP, } from "./template.js";
|
|
34
34
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
35
|
+
/**
|
|
36
|
+
* Produce the two-segment short id form of a writ id for human-readable
|
|
37
|
+
* resolution text (e.g. `w-abc123`). Mirrors the canonical implementation
|
|
38
|
+
* in `ratchet/src/tools/click-tree.ts`; replicated inline here to avoid a
|
|
39
|
+
* Spider → Ratchet dependency for a three-line utility.
|
|
40
|
+
*/
|
|
41
|
+
function shortId(id) {
|
|
42
|
+
return id.split('-').slice(0, 2).join('-');
|
|
43
|
+
}
|
|
35
44
|
/**
|
|
36
45
|
* Check whether a value is JSON-serializable.
|
|
37
46
|
* Non-serializable yields cause engine failure — the Stacks cannot store them.
|
|
@@ -1485,6 +1494,204 @@ export function createSpider() {
|
|
|
1485
1494
|
*
|
|
1486
1495
|
* Find the oldest open writ with no existing rig. Create a rig for it.
|
|
1487
1496
|
*/
|
|
1497
|
+
// ── spider.follows gate evaluation ──────────────────────────────────
|
|
1498
|
+
//
|
|
1499
|
+
// These are the terminal phase-categories the gate cares about. A blocker
|
|
1500
|
+
// in a terminal-success state (completed or cancelled) releases its edge;
|
|
1501
|
+
// a blocker that has `failed` cascades the dependent into stuck; any
|
|
1502
|
+
// non-terminal phase (new/open/stuck) holds the gate.
|
|
1503
|
+
const TERMINAL_SUCCESS_PHASES = new Set(['completed', 'cancelled']);
|
|
1504
|
+
const TERMINAL_PHASES = new Set(['completed', 'failed', 'cancelled']);
|
|
1505
|
+
/** Fetch outbound `spider.follows` target ids for a given writ id. */
|
|
1506
|
+
async function getSpiderFollowsTargets(writId) {
|
|
1507
|
+
const { outbound } = await clerk.links(writId);
|
|
1508
|
+
return outbound
|
|
1509
|
+
.filter((l) => l.kind === 'spider.follows')
|
|
1510
|
+
.map((l) => l.targetId);
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Walk the candidate's outbound `spider.follows` edges.
|
|
1514
|
+
*
|
|
1515
|
+
* Decisions about the candidate's gate state come from the **direct**
|
|
1516
|
+
* outbound edges only (per D14). Cycle detection, however, runs an
|
|
1517
|
+
* iterative DFS over the full transitive closure so back-edges that
|
|
1518
|
+
* only reveal themselves two or more hops out still fire.
|
|
1519
|
+
*
|
|
1520
|
+
* Per D3: `visiting` (in-stack) distinguishes back-edges from forward
|
|
1521
|
+
* re-visits. `visited` (fully explored) keeps diamonds from being
|
|
1522
|
+
* mistaken for cycles — a node reachable through two paths but never
|
|
1523
|
+
* appearing on the current DFS stack is a diamond, not a cycle.
|
|
1524
|
+
*/
|
|
1525
|
+
async function evaluateGate(candidateId) {
|
|
1526
|
+
const directTargets = await getSpiderFollowsTargets(candidateId);
|
|
1527
|
+
// Fast path: no outbound spider.follows at all → nothing to gate on.
|
|
1528
|
+
if (directTargets.length === 0)
|
|
1529
|
+
return { kind: 'ready' };
|
|
1530
|
+
// First: run a DFS for cycle detection over the transitive closure.
|
|
1531
|
+
const visiting = new Set();
|
|
1532
|
+
const visited = new Set();
|
|
1533
|
+
// Parent tracker: at the time we add `child` to visiting, we record
|
|
1534
|
+
// parent[child] = current node. We use it to reconstruct the cycle
|
|
1535
|
+
// path when a back-edge fires.
|
|
1536
|
+
const parent = new Map();
|
|
1537
|
+
const stack = [{ kind: 'enter', id: candidateId, parent: null }];
|
|
1538
|
+
while (stack.length > 0) {
|
|
1539
|
+
const frame = stack.pop();
|
|
1540
|
+
if (frame.kind === 'leave') {
|
|
1541
|
+
visiting.delete(frame.id);
|
|
1542
|
+
visited.add(frame.id);
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
// enter
|
|
1546
|
+
if (visited.has(frame.id))
|
|
1547
|
+
continue; // diamond — not a cycle
|
|
1548
|
+
if (visiting.has(frame.id)) {
|
|
1549
|
+
// Back-edge: frame.id is already on the current DFS stack. Walk
|
|
1550
|
+
// up via `parent` from the discovering node (frame.parent) until
|
|
1551
|
+
// we hit frame.id, then include frame.id itself. These are the
|
|
1552
|
+
// cycle members.
|
|
1553
|
+
const members = [];
|
|
1554
|
+
let cursor = frame.parent;
|
|
1555
|
+
while (cursor !== null && cursor !== frame.id) {
|
|
1556
|
+
members.push(cursor);
|
|
1557
|
+
cursor = parent.get(cursor) ?? null;
|
|
1558
|
+
}
|
|
1559
|
+
members.push(frame.id);
|
|
1560
|
+
return { kind: 'cycle', members };
|
|
1561
|
+
}
|
|
1562
|
+
visiting.add(frame.id);
|
|
1563
|
+
parent.set(frame.id, frame.parent);
|
|
1564
|
+
// Schedule leave *before* enqueuing children so they run first (LIFO).
|
|
1565
|
+
stack.push({ kind: 'leave', id: frame.id, parent: frame.parent });
|
|
1566
|
+
const outboundTargets = await getSpiderFollowsTargets(frame.id);
|
|
1567
|
+
for (const t of outboundTargets) {
|
|
1568
|
+
stack.push({ kind: 'enter', id: t, parent: frame.id });
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
// No cycle — classify the direct blockers by target phase.
|
|
1572
|
+
const failedBlockers = [];
|
|
1573
|
+
const nonTerminalBlockers = [];
|
|
1574
|
+
for (const targetId of directTargets) {
|
|
1575
|
+
const target = await writsBook.get(targetId);
|
|
1576
|
+
if (!target) {
|
|
1577
|
+
// Dangling reference — treat as non-terminal hold. The link was
|
|
1578
|
+
// created against a live target, so a missing target is an
|
|
1579
|
+
// operator/data-integrity condition better surfaced as "still
|
|
1580
|
+
// gated" than as "ready".
|
|
1581
|
+
nonTerminalBlockers.push(targetId);
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
const phase = target.phase;
|
|
1585
|
+
if (phase === 'failed') {
|
|
1586
|
+
failedBlockers.push(targetId);
|
|
1587
|
+
}
|
|
1588
|
+
else if (TERMINAL_SUCCESS_PHASES.has(phase)) {
|
|
1589
|
+
// Released — no-op.
|
|
1590
|
+
}
|
|
1591
|
+
else {
|
|
1592
|
+
nonTerminalBlockers.push(targetId);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (failedBlockers.length > 0) {
|
|
1596
|
+
return { kind: 'failed-blocker', blockerIds: failedBlockers };
|
|
1597
|
+
}
|
|
1598
|
+
if (nonTerminalBlockers.length > 0) {
|
|
1599
|
+
return { kind: 'gated', blockerIds: nonTerminalBlockers };
|
|
1600
|
+
}
|
|
1601
|
+
return { kind: 'ready' };
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Stick a writ because its gate evaluation produced a stuck condition.
|
|
1605
|
+
* Writes both the phase transition (via Clerk) and the provenance slot
|
|
1606
|
+
* (via setWritStatus) — the load-bearing `status.spider.stuckCause` is
|
|
1607
|
+
* the signal `autoUnstick` uses to re-evaluate later.
|
|
1608
|
+
*/
|
|
1609
|
+
async function stuckFromGate(writId, cause, blockerIds, resolution) {
|
|
1610
|
+
await clerk.transition(writId, 'stuck', { resolution });
|
|
1611
|
+
const status = {
|
|
1612
|
+
stuckCause: cause,
|
|
1613
|
+
blockerIds,
|
|
1614
|
+
observedAt: new Date().toISOString(),
|
|
1615
|
+
};
|
|
1616
|
+
await clerk.setWritStatus(writId, 'spider', status);
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Phase: autoUnstick.
|
|
1620
|
+
*
|
|
1621
|
+
* Re-evaluate every writ Spider previously stuck through the gating
|
|
1622
|
+
* path. Writs without `status.spider?.stuckCause` are skipped entirely
|
|
1623
|
+
* — the engine-cascade stuck path never writes that slot, so this is
|
|
1624
|
+
* how we avoid touching those (D20 / D5).
|
|
1625
|
+
*
|
|
1626
|
+
* Release conditions (D15):
|
|
1627
|
+
* - `failed-blocker`: every recorded blocker id is now in a
|
|
1628
|
+
* terminal-success phase. (If any are still failed or
|
|
1629
|
+
* non-terminal, keep the writ stuck.)
|
|
1630
|
+
* - `cycle`: any recorded cycle member has moved out of the cycle
|
|
1631
|
+
* (non-open phase, or no longer has the originally-observed
|
|
1632
|
+
* outbound `spider.follows` edge — the simplest proxy is that at
|
|
1633
|
+
* least one member is no longer in `open`). The next poll's
|
|
1634
|
+
* trySpawn pass will re-evaluate the gate; if the cycle remains,
|
|
1635
|
+
* it re-sticks.
|
|
1636
|
+
*
|
|
1637
|
+
* Returns the first successful unstick as a CrawlResult so callers
|
|
1638
|
+
* can observe progress; returns null when nothing was unsticked.
|
|
1639
|
+
*/
|
|
1640
|
+
async function autoUnstick() {
|
|
1641
|
+
const stuckWrits = await writsBook.find({
|
|
1642
|
+
where: [['phase', '=', 'stuck']],
|
|
1643
|
+
});
|
|
1644
|
+
for (const writ of stuckWrits) {
|
|
1645
|
+
const spiderStatus = writ.status?.spider;
|
|
1646
|
+
const cause = spiderStatus?.stuckCause;
|
|
1647
|
+
if (!cause)
|
|
1648
|
+
continue; // engine-cascade stuck or operator stuck — not ours
|
|
1649
|
+
const blockerIds = spiderStatus?.blockerIds ?? [];
|
|
1650
|
+
if (cause === 'failed-blocker') {
|
|
1651
|
+
// Every blocker must now be in a terminal-success state.
|
|
1652
|
+
let allResolved = true;
|
|
1653
|
+
for (const blockerId of blockerIds) {
|
|
1654
|
+
const blocker = await writsBook.get(blockerId);
|
|
1655
|
+
if (!blocker) {
|
|
1656
|
+
// Blocker disappeared — treat as resolved (no longer a gate).
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
1659
|
+
if (!TERMINAL_SUCCESS_PHASES.has(blocker.phase)) {
|
|
1660
|
+
allResolved = false;
|
|
1661
|
+
break;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
if (!allResolved)
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
else if (cause === 'cycle') {
|
|
1668
|
+
// Release if any cycle member has moved out of `open` — the
|
|
1669
|
+
// cycle can only persist while every member is still open.
|
|
1670
|
+
let brokenByAnyMember = false;
|
|
1671
|
+
for (const memberId of blockerIds) {
|
|
1672
|
+
const member = await writsBook.get(memberId);
|
|
1673
|
+
if (!member) {
|
|
1674
|
+
brokenByAnyMember = true;
|
|
1675
|
+
break;
|
|
1676
|
+
}
|
|
1677
|
+
if (member.id === writ.id)
|
|
1678
|
+
continue; // self is currently stuck
|
|
1679
|
+
if (member.phase !== 'open' && member.phase !== 'stuck') {
|
|
1680
|
+
brokenByAnyMember = true;
|
|
1681
|
+
break;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
if (!brokenByAnyMember)
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
1687
|
+
// Release: stuck → open. Clear the spider sub-slot (so future
|
|
1688
|
+
// `autoUnstick` passes don't revisit this writ).
|
|
1689
|
+
await clerk.transition(writ.id, 'open');
|
|
1690
|
+
await clerk.setWritStatus(writ.id, 'spider', {});
|
|
1691
|
+
return { action: 'writ-unstuck', writId: writ.id };
|
|
1692
|
+
}
|
|
1693
|
+
return null;
|
|
1694
|
+
}
|
|
1488
1695
|
async function trySpawn() {
|
|
1489
1696
|
// Throttle: do not spawn new rigs if system-wide engine limit is reached.
|
|
1490
1697
|
// Spawned rigs would just sit with their first engine in pending, cluttering the rig list.
|
|
@@ -1517,6 +1724,41 @@ export function createSpider() {
|
|
|
1517
1724
|
});
|
|
1518
1725
|
if (existing.length > 0)
|
|
1519
1726
|
continue;
|
|
1727
|
+
// Gate on outbound spider.follows links before dispatch. Evaluation
|
|
1728
|
+
// produces one of four outcomes — see `evaluateGate`. The walk also
|
|
1729
|
+
// runs cycle detection; back-edges surface as a `cycle` outcome.
|
|
1730
|
+
const gate = await evaluateGate(writ.id);
|
|
1731
|
+
if (gate.kind === 'gated') {
|
|
1732
|
+
// Non-terminal blockers — hold dispatch. Nothing is written to
|
|
1733
|
+
// status (D1: the gate-but-not-stuck state is not persisted). We
|
|
1734
|
+
// continue to the next candidate so a later, unblocked writ can
|
|
1735
|
+
// still dispatch this tick.
|
|
1736
|
+
return { action: 'gated', writId: writ.id, blockerIds: gate.blockerIds };
|
|
1737
|
+
}
|
|
1738
|
+
if (gate.kind === 'failed-blocker') {
|
|
1739
|
+
const shortIds = gate.blockerIds.map(shortId);
|
|
1740
|
+
const resolution = gate.blockerIds.length === 1
|
|
1741
|
+
? `Blocked by failed dependency: ${shortIds[0]}`
|
|
1742
|
+
: `Blocked by failed dependencies: ${shortIds.join(', ')}`;
|
|
1743
|
+
await stuckFromGate(writ.id, 'failed-blocker', gate.blockerIds, resolution);
|
|
1744
|
+
return { action: 'gated', writId: writ.id, blockerIds: gate.blockerIds };
|
|
1745
|
+
}
|
|
1746
|
+
if (gate.kind === 'cycle') {
|
|
1747
|
+
// Stick every member of the cycle with stuckCause='cycle'. Only
|
|
1748
|
+
// members still in `open` are transitioned — a member already
|
|
1749
|
+
// in another phase (e.g. stuck from a prior detection) just gets
|
|
1750
|
+
// its provenance slot rewritten with the fresh observedAt.
|
|
1751
|
+
for (const memberId of gate.members) {
|
|
1752
|
+
const member = await writsBook.get(memberId);
|
|
1753
|
+
if (!member)
|
|
1754
|
+
continue;
|
|
1755
|
+
if (member.phase === 'open') {
|
|
1756
|
+
await stuckFromGate(memberId, 'cycle', gate.members, 'Cycle detected in spider.follows graph');
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
return { action: 'gated', writId: writ.id, blockerIds: gate.members };
|
|
1760
|
+
}
|
|
1761
|
+
// gate.kind === 'ready' — fall through to dispatch.
|
|
1520
1762
|
// The query-level type filter above guarantees this lookup succeeds.
|
|
1521
1763
|
// A null here would mean the registry's mappings diverged from what
|
|
1522
1764
|
// listTemplateMappings() returned mid-crawl — an invariant violation.
|
|
@@ -1558,6 +1800,13 @@ export function createSpider() {
|
|
|
1558
1800
|
const ran = await tryRun();
|
|
1559
1801
|
if (ran)
|
|
1560
1802
|
return ran;
|
|
1803
|
+
// Before trySpawn: re-evaluate writs Spider previously stuck via the
|
|
1804
|
+
// gating path. `autoUnstick` skips writs without
|
|
1805
|
+
// `status.spider?.stuckCause` so the engine-cascade stuck path stays
|
|
1806
|
+
// untouched (D6).
|
|
1807
|
+
const unstuck = await autoUnstick();
|
|
1808
|
+
if (unstuck)
|
|
1809
|
+
return unstuck;
|
|
1561
1810
|
const spawned = await trySpawn();
|
|
1562
1811
|
if (spawned)
|
|
1563
1812
|
return spawned;
|
|
@@ -1698,6 +1947,12 @@ export function createSpider() {
|
|
|
1698
1947
|
indexes: ['status', 'rigId', 'engineId', 'createdAt', ['rigId', 'engineId', 'status']],
|
|
1699
1948
|
},
|
|
1700
1949
|
},
|
|
1950
|
+
linkKinds: [
|
|
1951
|
+
{
|
|
1952
|
+
id: 'spider.follows',
|
|
1953
|
+
description: 'The source writ is a precedence-successor of the target: source cannot be dispatched until the target reaches a terminal state. Consumers define their own policy for what happens on each terminal state.',
|
|
1954
|
+
},
|
|
1955
|
+
],
|
|
1701
1956
|
engines: {
|
|
1702
1957
|
'anima-session': animaSessionEngine,
|
|
1703
1958
|
draft: draftEngine,
|