@dotdrelle/wiki-manager 0.15.42 → 0.15.48
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 +139 -27
- package/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +290 -29
- package/src/agent/graph.test.js +551 -1
- package/src/agent/skillRecursion.test.js +98 -0
- package/src/cli/wiki-manager.js +209 -7
- package/src/cli/wiki-manager.test.js +89 -0
- package/src/commands/slash.js +28 -10
- package/src/contracts/schemas.js +1 -1
- package/src/core/agentEvents.js +50 -0
- package/src/core/agentEvents.test.js +52 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/env.js +20 -1
- package/src/core/env.test.js +34 -0
- package/src/core/mcp.js +1 -1
- package/src/core/profile.js +19 -0
- package/src/core/runtimeLog.js +15 -0
- package/src/core/runtimeLog.test.js +15 -1
- package/src/core/skillChainView.js +84 -0
- package/src/core/skillChainView.test.js +50 -0
- package/src/core/skillCompiler.js +135 -0
- package/src/core/skillCompiler.test.js +91 -0
- package/src/core/skillInvocation.js +79 -0
- package/src/core/skillInvocation.test.js +73 -0
- package/src/core/skills.js +81 -19
- package/src/core/wikiWorkspace.test.js +34 -0
- package/src/core/workspaceProfile.test.js +55 -0
- package/src/runtime/client.js +45 -4
- package/src/runtime/controlCancellation.js +33 -0
- package/src/runtime/controlCancellation.test.js +49 -0
- package/src/runtime/controlDrain.js +50 -0
- package/src/runtime/controlDrain.test.js +38 -0
- package/src/runtime/server.js +341 -20
- package/src/runtime/server.test.js +344 -2
- package/src/runtime/skillChain.e2e.test.js +394 -0
- package/src/runtime/skillRun.js +104 -0
- package/src/runtime/skillRun.test.js +84 -0
- package/src/runtime/store.js +69 -0
- package/src/runtime/store.test.js +11 -0
- package/src/runtime/workspaceIsolation.test.js +178 -0
- package/src/shell/RightPane.tsx +3 -2
- package/src/shell/repl.js +51 -6
- package/src/shell/repl.test.js +41 -0
- package/src/shell/useSession.ts +43 -9
- package/wiki-workspace +137 -1
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
3
6
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
7
|
import { createInteractiveSession, ensureInteractiveAssistantMessage } from '../cli/wiki-manager.js';
|
|
8
|
+
import { postRuntimeRun } from './client.js';
|
|
5
9
|
import { approvalRequestFromStatus, runtimeState, startRuntimeServer as startRuntimeServerImpl } from './server.js';
|
|
6
10
|
|
|
7
11
|
// Most server tests exercise endpoint behavior rather than authentication. Keep
|
|
@@ -11,6 +15,45 @@ function startRuntimeServer(options) {
|
|
|
11
15
|
return startRuntimeServerImpl({ token: '', ...options });
|
|
12
16
|
}
|
|
13
17
|
|
|
18
|
+
test('runtime run client surfaces the server error instead of only the HTTP status', async () => {
|
|
19
|
+
const originalFetch = globalThis.fetch;
|
|
20
|
+
globalThis.fetch = async () => new Response(JSON.stringify({
|
|
21
|
+
error: 'Workspace services are unavailable. Start them with /start all, then retry.',
|
|
22
|
+
code: 'services_unavailable',
|
|
23
|
+
}), {
|
|
24
|
+
status: 400,
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await assert.rejects(
|
|
30
|
+
postRuntimeRun('build the wiki', { workspace: 'demo' }),
|
|
31
|
+
(error) => {
|
|
32
|
+
assert.equal(error.message, 'Runtime run failed: HTTP 400 — Workspace services are unavailable. Start them with /start all, then retry.');
|
|
33
|
+
assert.equal(error.status, 400);
|
|
34
|
+
assert.equal(error.code, 'services_unavailable');
|
|
35
|
+
return true;
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
} finally {
|
|
39
|
+
globalThis.fetch = originalFetch;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('runtime run client keeps the HTTP fallback without a JSON error body', async () => {
|
|
44
|
+
const originalFetch = globalThis.fetch;
|
|
45
|
+
globalThis.fetch = async () => new Response('Bad request', { status: 400 });
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await assert.rejects(
|
|
49
|
+
postRuntimeRun('build the wiki', { workspace: 'demo' }),
|
|
50
|
+
/Runtime run failed: HTTP 400/,
|
|
51
|
+
);
|
|
52
|
+
} finally {
|
|
53
|
+
globalThis.fetch = originalFetch;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
14
57
|
test('approval fallback derives classes from waiting tasks when the approval queue is missing', () => {
|
|
15
58
|
const request = approvalRequestFromStatus({
|
|
16
59
|
workspace: 'acme',
|
|
@@ -1485,6 +1528,304 @@ test('runtime server handle drains a pre-existing hydrated control request', asy
|
|
|
1485
1528
|
}
|
|
1486
1529
|
});
|
|
1487
1530
|
|
|
1531
|
+
test('POST /run compiles a workspace skill into a sequential runtime chain', async (t) => {
|
|
1532
|
+
const root = mkdtempSync(join(tmpdir(), 'runtime-skill-'));
|
|
1533
|
+
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
1534
|
+
writeFileSync(join(root, '.wiki', 'skills', 'wiki-sync.md'), '---\nname: wiki-sync\nparams:\n - source\n---\nExport the source.\n\nThen ingest the files.');
|
|
1535
|
+
const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
|
|
1536
|
+
const context = { workspace: 'acme', session, running: false, currentAbortController: null };
|
|
1537
|
+
let startedBody = null;
|
|
1538
|
+
let handle;
|
|
1539
|
+
try {
|
|
1540
|
+
handle = await startRuntimeServer({
|
|
1541
|
+
host: '127.0.0.1', port: 0,
|
|
1542
|
+
store: { dbPath: ':memory:', getState: () => ({ status: 'idle', plan: [], queue: [], approvals: [] }), listEvents: () => [] },
|
|
1543
|
+
getContext: async () => context,
|
|
1544
|
+
run: async (_context, body) => { startedBody = body; return new Promise(() => {}); },
|
|
1545
|
+
});
|
|
1546
|
+
} catch (err) {
|
|
1547
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1548
|
+
throw err;
|
|
1549
|
+
}
|
|
1550
|
+
try {
|
|
1551
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ input: '/wiki-sync docs' }) });
|
|
1552
|
+
const body = await response.json();
|
|
1553
|
+
assert.equal(response.status, 202);
|
|
1554
|
+
assert.equal(body.kind, 'skill_chain');
|
|
1555
|
+
assert.equal(body.objectives, 2);
|
|
1556
|
+
assert.equal(session.controlQueue.length, 2);
|
|
1557
|
+
assert.equal(session.controlQueue[0].status, 'running');
|
|
1558
|
+
assert.equal(session.controlQueue[1].status, 'queued');
|
|
1559
|
+
assert.equal(session.controlQueue[0].chainId, session.controlQueue[1].chainId);
|
|
1560
|
+
assert.equal('capabilityPlan' in session.controlQueue[0], false);
|
|
1561
|
+
assert.equal(session.controlQueue[0].input, '/wiki-sync source="docs"');
|
|
1562
|
+
assert.equal(session.controlQueue[0].skillExecution, 'orchestrated');
|
|
1563
|
+
assert.match(startedBody.input, /Export the source/);
|
|
1564
|
+
assert.equal(startedBody.publicInput, '/wiki-sync source="docs"');
|
|
1565
|
+
assert.equal(startedBody.skillChain.execution, 'orchestrated');
|
|
1566
|
+
assert.equal(startedBody.requireApproval, true);
|
|
1567
|
+
assert.notEqual(startedBody.autoApprove, true);
|
|
1568
|
+
assert.deepEqual(session.agentProjection.conversation, [
|
|
1569
|
+
{ role: 'user', content: '/wiki-sync docs' },
|
|
1570
|
+
]);
|
|
1571
|
+
assert.doesNotMatch(JSON.stringify(session.agentEvents), /Export the source|Then ingest the files/);
|
|
1572
|
+
} finally {
|
|
1573
|
+
context.currentAbortController?.abort();
|
|
1574
|
+
await handle.close();
|
|
1575
|
+
}
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
test('POST /turn deterministically compiles an explicit skill invocation', async (t) => {
|
|
1579
|
+
const root = mkdtempSync(join(tmpdir(), 'runtime-skill-turn-'));
|
|
1580
|
+
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
1581
|
+
writeFileSync(join(root, '.wiki', 'skills', 'wiki-build.md'), '---\nname: wiki-build\nparams:\n - template\n---\nBuild the requested template.');
|
|
1582
|
+
const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
|
|
1583
|
+
const context = { workspace: 'acme', session, running: false, currentAbortController: null };
|
|
1584
|
+
const turns = [];
|
|
1585
|
+
let handle;
|
|
1586
|
+
try {
|
|
1587
|
+
handle = await startRuntimeServer({
|
|
1588
|
+
host: '127.0.0.1', port: 0,
|
|
1589
|
+
store: { dbPath: ':memory:', getState: () => ({ status: 'idle', plan: [], queue: [], approvals: [] }), listEvents: () => [] },
|
|
1590
|
+
getContext: async () => context,
|
|
1591
|
+
run: async () => new Promise(() => {}),
|
|
1592
|
+
turn: async (_context, body) => { turns.push(body); return { ok: true }; },
|
|
1593
|
+
});
|
|
1594
|
+
} catch (err) {
|
|
1595
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1596
|
+
throw err;
|
|
1597
|
+
}
|
|
1598
|
+
try {
|
|
1599
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
|
|
1600
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1601
|
+
body: JSON.stringify({ input: '/wiki-build overview', mode: 'agent' }),
|
|
1602
|
+
});
|
|
1603
|
+
const body = await response.json();
|
|
1604
|
+
assert.equal(response.status, 202);
|
|
1605
|
+
assert.equal(body.kind, 'skill_chain');
|
|
1606
|
+
assert.equal(body.skill, 'wiki-build');
|
|
1607
|
+
assert.equal(turns.length, 0);
|
|
1608
|
+
assert.equal(session.controlQueue.length, 1);
|
|
1609
|
+
assert.equal(session.controlQueue[0].input, '/wiki-build template="overview"');
|
|
1610
|
+
assert.deepEqual(session.agentProjection.conversation, [
|
|
1611
|
+
{ role: 'user', content: '/wiki-build overview' },
|
|
1612
|
+
]);
|
|
1613
|
+
} finally {
|
|
1614
|
+
context.currentAbortController?.abort();
|
|
1615
|
+
await handle.close();
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
test('POST /turn keeps informational skill and build questions conversational', async (t) => {
|
|
1620
|
+
const root = mkdtempSync(join(tmpdir(), 'runtime-skill-question-'));
|
|
1621
|
+
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
1622
|
+
writeFileSync(join(root, '.wiki', 'skills', 'new-template.md'), '---\nname: new-template\nparams:\n - family\n---\nCreate one template.');
|
|
1623
|
+
const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
|
|
1624
|
+
const context = { workspace: 'acme', session, running: false, currentAbortController: null };
|
|
1625
|
+
const turns = [];
|
|
1626
|
+
let handle;
|
|
1627
|
+
try {
|
|
1628
|
+
handle = await startRuntimeServer({
|
|
1629
|
+
host: '127.0.0.1', port: 0,
|
|
1630
|
+
store: { dbPath: ':memory:', getState: () => ({ status: 'idle', plan: [], queue: [], approvals: [] }), listEvents: () => [] },
|
|
1631
|
+
getContext: async () => context,
|
|
1632
|
+
run: async () => new Promise(() => {}),
|
|
1633
|
+
turn: async (_context, body) => { turns.push(body); return { ok: true }; },
|
|
1634
|
+
});
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1637
|
+
throw err;
|
|
1638
|
+
}
|
|
1639
|
+
try {
|
|
1640
|
+
for (const input of ['Comment fonctionne new-template ?', "Qu'est-ce qu'un build ?"]) {
|
|
1641
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
|
|
1642
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1643
|
+
body: JSON.stringify({ input, mode: 'agent' }),
|
|
1644
|
+
});
|
|
1645
|
+
const body = await response.json();
|
|
1646
|
+
assert.equal(response.status, 202);
|
|
1647
|
+
assert.equal(body.kind, 'turn');
|
|
1648
|
+
}
|
|
1649
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1650
|
+
assert.deepEqual(turns.map((body) => body.input), [
|
|
1651
|
+
'Comment fonctionne new-template ?',
|
|
1652
|
+
"Qu'est-ce qu'un build ?",
|
|
1653
|
+
]);
|
|
1654
|
+
assert.equal(session.controlQueue.length, 0);
|
|
1655
|
+
assert.equal(context.running, false);
|
|
1656
|
+
} finally {
|
|
1657
|
+
await handle.close();
|
|
1658
|
+
}
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
test('POST /run accepts named skill arguments and deduplicates an explicit retry key', async (t) => {
|
|
1662
|
+
const root = mkdtempSync(join(tmpdir(), 'runtime-named-skill-'));
|
|
1663
|
+
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
1664
|
+
writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n - template\n - polish\n---\nDeliver the output.');
|
|
1665
|
+
const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
|
|
1666
|
+
const context = { workspace: 'acme', session, running: false, currentAbortController: null };
|
|
1667
|
+
const persisted = new Map();
|
|
1668
|
+
let handle;
|
|
1669
|
+
try {
|
|
1670
|
+
handle = await startRuntimeServer({
|
|
1671
|
+
host: '127.0.0.1', port: 0,
|
|
1672
|
+
store: {
|
|
1673
|
+
dbPath: ':memory:', getState: () => ({ status: 'idle', plan: [], queue: [], approvals: [] }), listEvents: () => [],
|
|
1674
|
+
findSkillRun: ({ idempotencyKey }) => persisted.get(idempotencyKey) ?? null,
|
|
1675
|
+
persistSkillRun: ({ idempotencyKey, chainId }) => persisted.set(idempotencyKey, chainId),
|
|
1676
|
+
},
|
|
1677
|
+
getContext: async () => context,
|
|
1678
|
+
run: async () => new Promise(() => {}),
|
|
1679
|
+
});
|
|
1680
|
+
} catch (err) {
|
|
1681
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1682
|
+
throw err;
|
|
1683
|
+
}
|
|
1684
|
+
try {
|
|
1685
|
+
const request = () => fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, {
|
|
1686
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1687
|
+
body: JSON.stringify({ input: '/deliver', skillName: 'deliver', skillArguments: { template: 'Quarterly report' }, idempotencyKey: 'retry-1' }),
|
|
1688
|
+
});
|
|
1689
|
+
const first = await (await request()).json();
|
|
1690
|
+
const second = await (await request()).json();
|
|
1691
|
+
assert.equal(first.accepted, true);
|
|
1692
|
+
assert.equal(second.deduplicated, true);
|
|
1693
|
+
assert.equal(second.chainId, first.chainId);
|
|
1694
|
+
assert.equal(session.controlQueue.length, 1);
|
|
1695
|
+
assert.equal('input' in first.items[0], false);
|
|
1696
|
+
assert.equal('objectives' in first, false);
|
|
1697
|
+
assert.equal(session.controlQueue[0].input, '/deliver template="Quarterly report"');
|
|
1698
|
+
} finally {
|
|
1699
|
+
context.currentAbortController?.abort();
|
|
1700
|
+
await handle.close();
|
|
1701
|
+
}
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
test('POST /run does not unlock a reserved skill with a mismatched skillName', async (t) => {
|
|
1705
|
+
const root = mkdtempSync(join(tmpdir(), 'runtime-reserved-skill-'));
|
|
1706
|
+
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
1707
|
+
writeFileSync(join(root, '.wiki', 'skills', 'status.md'), '---\nname: status\nparams: []\n---\nRun the workspace status skill.');
|
|
1708
|
+
const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
|
|
1709
|
+
const context = { workspace: 'acme', session, running: false, currentAbortController: null };
|
|
1710
|
+
let received = null;
|
|
1711
|
+
let handle;
|
|
1712
|
+
try {
|
|
1713
|
+
handle = await startRuntimeServer({
|
|
1714
|
+
host: '127.0.0.1', port: 0,
|
|
1715
|
+
store: { dbPath: ':memory:', getState: () => ({ status: 'idle', plan: [], queue: [], approvals: [] }), listEvents: () => [] },
|
|
1716
|
+
getContext: async () => context,
|
|
1717
|
+
run: async (_context, body) => { received = body; },
|
|
1718
|
+
});
|
|
1719
|
+
} catch (err) {
|
|
1720
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1721
|
+
throw err;
|
|
1722
|
+
}
|
|
1723
|
+
try {
|
|
1724
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, {
|
|
1725
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1726
|
+
body: JSON.stringify({ input: '/status', skillName: 'anything' }),
|
|
1727
|
+
});
|
|
1728
|
+
const body = await response.json();
|
|
1729
|
+
assert.equal(response.status, 202);
|
|
1730
|
+
assert.notEqual(body.kind, 'skill_chain');
|
|
1731
|
+
assert.equal(session.controlQueue.length, 0);
|
|
1732
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1733
|
+
assert.equal(received.input, '/status');
|
|
1734
|
+
} finally {
|
|
1735
|
+
context.currentAbortController?.abort();
|
|
1736
|
+
await handle.close();
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
test('structured named arguments unlock a reserved skill only when input names the same skill', async (t) => {
|
|
1741
|
+
const root = mkdtempSync(join(tmpdir(), 'runtime-structured-reserved-'));
|
|
1742
|
+
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
1743
|
+
writeFileSync(join(root, '.wiki', 'skills', 'status.md'), '---\nname: status\n---\nRun workspace status.');
|
|
1744
|
+
const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
|
|
1745
|
+
const context = { workspace: 'acme', session, running: false, currentAbortController: null };
|
|
1746
|
+
let handle;
|
|
1747
|
+
try {
|
|
1748
|
+
handle = await startRuntimeServer({
|
|
1749
|
+
host: '127.0.0.1', port: 0,
|
|
1750
|
+
store: { dbPath: ':memory:', getState: () => ({ status: 'idle', plan: [], queue: [], approvals: [] }), listEvents: () => [] },
|
|
1751
|
+
getContext: async () => context,
|
|
1752
|
+
run: async () => new Promise(() => {}),
|
|
1753
|
+
});
|
|
1754
|
+
} catch (err) {
|
|
1755
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1756
|
+
throw err;
|
|
1757
|
+
}
|
|
1758
|
+
try {
|
|
1759
|
+
const post = (input) => fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, {
|
|
1760
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1761
|
+
body: JSON.stringify({ input, skillName: 'status', skillArguments: {} }),
|
|
1762
|
+
});
|
|
1763
|
+
const mismatch = await post('/deliver');
|
|
1764
|
+
assert.equal(mismatch.status, 400);
|
|
1765
|
+
assert.equal((await mismatch.json()).code, 'skill_name_mismatch');
|
|
1766
|
+
const explicit = await post('/status');
|
|
1767
|
+
assert.equal(explicit.status, 202);
|
|
1768
|
+
assert.equal((await explicit.json()).skill, 'status');
|
|
1769
|
+
} finally {
|
|
1770
|
+
context.currentAbortController?.abort();
|
|
1771
|
+
await handle.close();
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
test('legacy placeholders consume only their own parameter', async (t) => {
|
|
1776
|
+
const root = mkdtempSync(join(tmpdir(), 'runtime-partial-placeholder-'));
|
|
1777
|
+
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
1778
|
+
writeFileSync(join(root, '.wiki', 'skills', 'partial.md'), '---\nname: partial\nparams:\n - a\n - b\n---\nUse {a}.\n\nThen process the remaining input.');
|
|
1779
|
+
const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
|
|
1780
|
+
const context = { workspace: 'acme', session, running: false, currentAbortController: null };
|
|
1781
|
+
let handle;
|
|
1782
|
+
try {
|
|
1783
|
+
handle = await startRuntimeServer({
|
|
1784
|
+
host: '127.0.0.1', port: 0,
|
|
1785
|
+
store: { dbPath: ':memory:', getState: () => ({ status: 'idle', plan: [], queue: [], approvals: [] }), listEvents: () => [] },
|
|
1786
|
+
getContext: async () => context,
|
|
1787
|
+
run: async () => new Promise(() => {}),
|
|
1788
|
+
});
|
|
1789
|
+
} catch (err) {
|
|
1790
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1791
|
+
throw err;
|
|
1792
|
+
}
|
|
1793
|
+
try {
|
|
1794
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, {
|
|
1795
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1796
|
+
body: JSON.stringify({ input: '/partial alpha beta' }),
|
|
1797
|
+
});
|
|
1798
|
+
const body = await response.json();
|
|
1799
|
+
assert.equal(response.status, 202);
|
|
1800
|
+
assert.deepEqual(body.deprecatedPlaceholders, ['a']);
|
|
1801
|
+
assert.equal(session.controlQueue.length, 2);
|
|
1802
|
+
for (const item of session.controlQueue) assert.equal(item.input, '/partial a="alpha" b="beta"');
|
|
1803
|
+
} finally {
|
|
1804
|
+
context.currentAbortController?.abort();
|
|
1805
|
+
await handle.close();
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
|
|
1809
|
+
test('POST /control cancel_item returns a readable non-error for a non-queued item', async (t) => {
|
|
1810
|
+
const session = { workspace: 'acme', controlQueue: [{ id: 'done-item', status: 'done', input: 'done' }] };
|
|
1811
|
+
let handle;
|
|
1812
|
+
try {
|
|
1813
|
+
handle = await startRuntimeServer({
|
|
1814
|
+
host: '127.0.0.1', port: 0,
|
|
1815
|
+
store: { dbPath: ':memory:', getState: () => ({ status: 'idle', plan: [], queue: [], approvals: [] }), listEvents: () => [] },
|
|
1816
|
+
getContext: async () => ({ workspace: 'acme', session, running: false }), run: async () => {},
|
|
1817
|
+
});
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1820
|
+
throw err;
|
|
1821
|
+
}
|
|
1822
|
+
try {
|
|
1823
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cancel_item', id: 'done-item' }) });
|
|
1824
|
+
assert.equal(response.status, 200);
|
|
1825
|
+
assert.deepEqual(await response.json().then(({ cancelled, reason }) => ({ cancelled, reason })), { cancelled: false, reason: 'not_queued' });
|
|
1826
|
+
} finally { await handle.close(); }
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1488
1829
|
test('runtime server exposes config profile list and switch endpoints', async (t) => {
|
|
1489
1830
|
const context = {
|
|
1490
1831
|
workspace: 'acme',
|
|
@@ -1816,8 +2157,9 @@ test('redo truncation drops the aftermath of one question and refuses during a r
|
|
|
1816
2157
|
const ok = await post({ index: 0 });
|
|
1817
2158
|
assert.equal(ok.status, 200);
|
|
1818
2159
|
assert.deepEqual(await ok.json(), { truncated: true, index: 0, removedEvents: 1 });
|
|
1819
|
-
//
|
|
1820
|
-
|
|
2160
|
+
// Drops the question (sequence 1) along with everything after it: the
|
|
2161
|
+
// caller resubmits it, so keeping it here showed the same message twice.
|
|
2162
|
+
assert.deepEqual(deleted, { sequence: 0, workspace: 'demo' });
|
|
1821
2163
|
// getState prefers the in-memory projection, so the deleted answers would
|
|
1822
2164
|
// survive in RAM without this rehydration.
|
|
1823
2165
|
assert.equal(hydrated, true);
|