studiograph 1.3.48-next.248 → 1.3.48-next.249
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/dist/agent/tools/artifact-tools.d.ts +4 -0
- package/dist/agent/tools/artifact-tools.js.map +1 -1
- package/dist/agent/tools/folder-tools.d.ts +5 -2
- package/dist/agent/tools/folder-tools.js +83 -2
- package/dist/agent/tools/folder-tools.js.map +1 -1
- package/dist/core/types.d.ts +11 -0
- package/dist/core/types.js +20 -0
- package/dist/core/types.js.map +1 -1
- package/dist/core/workspace-manager.d.ts +20 -0
- package/dist/core/workspace-manager.js +60 -2
- package/dist/core/workspace-manager.js.map +1 -1
- package/dist/mcp/tools.d.ts +1 -1
- package/dist/mcp/tools.js +266 -160
- package/dist/mcp/tools.js.map +1 -1
- package/dist/server/routes/chat.js +67 -190
- package/dist/server/routes/chat.js.map +1 -1
- package/dist/server/routes/graph-api-access.d.ts +10 -4
- package/dist/server/routes/graph-api-access.js +8 -5
- package/dist/server/routes/graph-api-access.js.map +1 -1
- package/dist/server/routes/graph-api.js +79 -0
- package/dist/server/routes/graph-api.js.map +1 -1
- package/dist/server/routes/insights-api.js +4 -3
- package/dist/server/routes/insights-api.js.map +1 -1
- package/dist/services/artifact-store.d.ts +111 -0
- package/dist/services/artifact-store.js +235 -0
- package/dist/services/artifact-store.js.map +1 -0
- package/dist/services/assets/asset-index-service.d.ts +9 -0
- package/dist/services/assets/asset-index-service.js +11 -0
- package/dist/services/assets/asset-index-service.js.map +1 -1
- package/dist/services/workspace-access.d.ts +2 -0
- package/dist/web/_app/immutable/assets/SettingsDialog.DLCW88_K.css +1 -0
- package/dist/web/_app/immutable/chunks/{BxQgIVb7.js → 08ccpAwb.js} +2 -2
- package/dist/web/_app/immutable/chunks/{BNYeNIcr.js → 1A1PfCLT.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DTub4iP1.js → 4M-H5IF4.js} +2 -2
- package/dist/web/_app/immutable/chunks/{BSFQWjQm.js → 55R1MOwv.js} +1 -1
- package/dist/web/_app/immutable/chunks/{LWxcXHte.js → 8NMKteKC.js} +1 -1
- package/dist/web/_app/immutable/chunks/{MSGI-9Cc.js → B0GtSrm7.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DfCBxcND.js → B2TUq7Tx.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Cf_u2Ors.js → B3uzhlw4.js} +2 -2
- package/dist/web/_app/immutable/chunks/{Dc2mTYyI.js → B4Ff89DM.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BDOt0MKz2.js → B91IrHS82.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Du2Y3UVf.js → B9od0BVp.js} +2 -2
- package/dist/web/_app/immutable/chunks/{Cko1LC1k.js → BFTCBlcp.js} +1 -1
- package/dist/web/_app/immutable/chunks/{rBu_hmtL2.js → BHbilOQf2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DwfZHSHP.js → BICS1Iik.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Dah1D4RJ.js → BOsgZGlh.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BbDf9TiJ.js → BZ-0H8m-.js} +1 -1
- package/dist/web/_app/immutable/chunks/B_QIVR6w.js +1 -0
- package/dist/web/_app/immutable/chunks/{DH-WyHBA.js → BdDRk883.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DXQDje3_.js → Bdiorub0.js} +1 -1
- package/dist/web/_app/immutable/chunks/{UgJLJmun.js → Bents8FP.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DMdcZaF3.js → BeoCSH68.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CVEEIQ58.js → BfXeavEA.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Djr4EgfL2.js → BjGnI0gr2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CwJMw0Pa.js → Bnt16ZW4.js} +2 -2
- package/dist/web/_app/immutable/chunks/{DTy-F7gP.js → BxCUaJ3N.js} +1 -1
- package/dist/web/_app/immutable/chunks/{B10Sb-A12.js → By3Z2hTQ2.js} +2 -2
- package/dist/web/_app/immutable/chunks/{BowHuz-T.js → C1ZfMHej.js} +1 -1
- package/dist/web/_app/immutable/chunks/{B0DfJ9g_2.js → C3pPex232.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BdNVdD1A2.js → C8pVjgxL2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BckIHg4W2.js → CETcrIM92.js} +1 -1
- package/dist/web/_app/immutable/chunks/CIjrNgS6.js +11 -0
- package/dist/web/_app/immutable/chunks/{B7rkp-wA.js → CUdxuEbH.js} +1 -1
- package/dist/web/_app/immutable/chunks/{ZBVOB3SW.js → CYXSz9yb.js} +1 -1
- package/dist/web/_app/immutable/chunks/{C0z8Ad2G.js → CYxloZGb.js} +2 -2
- package/dist/web/_app/immutable/chunks/{BtRKhPh5.js → Cb1xGHhj.js} +1 -1
- package/dist/web/_app/immutable/chunks/{tkN39gp9.js → CeW2vAlc.js} +2 -2
- package/dist/web/_app/immutable/chunks/{W-sQF62y2.js → CorrHq532.js} +1 -1
- package/dist/web/_app/immutable/chunks/{v55oxZ8l.js → Crnl6Md_.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BYzD0vP7.js → CvR2ceWq.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BizPx2nv.js → D4hNQXDb.js} +1 -1
- package/dist/web/_app/immutable/chunks/{6iH0r6Nl.js → DI-vfMFw.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BLxNxM-5.js → DMN_9bHq.js} +1 -1
- package/dist/web/_app/immutable/chunks/{jfm0DJ6s.js → DNf-Rqo5.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DdGmozvg2.js → DTJ-iC7M2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DoJ7V80J2.js → DbW2ot-t2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BLQsxHKg.js → Dc91RPU4.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CZLrcjI0.js → DrDn1B4Y.js} +3 -3
- package/dist/web/_app/immutable/chunks/{tEQ2Yl1H2.js → Dt7xLBBR2.js} +2 -2
- package/dist/web/_app/immutable/chunks/{BCwIJPci2.js → DukVcMiZ2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Cs11ZH9x2.js → EZDfis8_2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{VCcJOGhl.js → I2KxFcoO.js} +1 -1
- package/dist/web/_app/immutable/chunks/{fSj1T3Iz.js → IR38yQ7g.js} +1 -1
- package/dist/web/_app/immutable/chunks/{-a871hs32.js → ScqH9xf82.js} +1 -1
- package/dist/web/_app/immutable/chunks/{NQSO2PAq.js → SfGK8Et-.js} +1 -1
- package/dist/web/_app/immutable/chunks/{J-14QkVJ.js → TQ8mmoY7.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DH7vY5uU2.js → Y1vnnZ_M2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CF98DJyq2.js → efxT-S2T2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BL8rA7r82.js → ktBgiG5W2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BjBXJI7P.js → n0hPbJlk.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DMWIs83C.js → qQ8b5slZ.js} +1 -1
- package/dist/web/_app/immutable/chunks/{0quobYVw2.js → tc5XRbdg2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Duw8Nwl1.js → z8SO2eB6.js} +2 -2
- package/dist/web/_app/immutable/entry/{app.xw45BMhX.js → app.BRo-5YLr.js} +2 -2
- package/dist/web/_app/immutable/entry/start.LPWH40A1.js +1 -0
- package/dist/web/_app/immutable/nodes/{0.DBgLDOcR.js → 0.Cz-OF0vW.js} +2 -2
- package/dist/web/_app/immutable/nodes/{1.B-BMuCT3.js → 1.1ArpZvPz.js} +1 -1
- package/dist/web/_app/immutable/nodes/{10.DEk6l-8a.js → 10.i2p1ywAL.js} +1 -1
- package/dist/web/_app/immutable/nodes/{11.JALOagtU.js → 11.Do63wBtK.js} +1 -1
- package/dist/web/_app/immutable/nodes/{12.BDlVwDIm.js → 12.Dux5L6Nc.js} +1 -1
- package/dist/web/_app/immutable/nodes/{13.CAYDF7h0.js → 13.Dk08N2ND.js} +1 -1
- package/dist/web/_app/immutable/nodes/{15.CojIp6Mm.js → 15.i29s6O-A.js} +1 -1
- package/dist/web/_app/immutable/nodes/{17.Dkuv0DbN.js → 17.DNWb99VO.js} +1 -1
- package/dist/web/_app/immutable/nodes/{18.ChIQzB8E.js → 18.DTyupqhr.js} +1 -1
- package/dist/web/_app/immutable/nodes/{2.Cb8RO_-f.js → 2.skcVmF1z.js} +4 -4
- package/dist/web/_app/immutable/nodes/{3.BnnVNK_n.js → 3.B5WBoJCl.js} +3 -3
- package/dist/web/_app/immutable/nodes/{4.D74o1QID.js → 4.C2jGw2l7.js} +3 -3
- package/dist/web/_app/immutable/nodes/{5.WcMBWdxt.js → 5.B48GFlrM.js} +1 -1
- package/dist/web/_app/immutable/nodes/{6.B084Jkrq.js → 6.m9OLVuUf.js} +3 -3
- package/dist/web/_app/immutable/nodes/{7.N-19azSR.js → 7.B_d0ei0n.js} +1 -1
- package/dist/web/_app/immutable/nodes/{8.tOur5mLJ.js → 8.DeVfsUW8.js} +1 -1
- package/dist/web/_app/immutable/nodes/{9.3Svt5uuJ.js → 9.Bp9VdZol.js} +1 -1
- package/dist/web/_app/version.json +1 -1
- package/dist/web/index.html +13 -13
- package/package.json +1 -1
- package/dist/web/_app/immutable/assets/SettingsDialog.Bdy6qXoG.css +0 -1
- package/dist/web/_app/immutable/chunks/DGS78XKn.js +0 -1
- package/dist/web/_app/immutable/chunks/Dt40mMXG.js +0 -8
- package/dist/web/_app/immutable/entry/start.X6Ea_iBL.js +0 -1
package/dist/mcp/tools.js
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { z } from 'zod';
|
|
18
18
|
import { ENTITY_SCHEMAS } from '../core/validation.js';
|
|
19
|
-
import {
|
|
19
|
+
import { isWorkspaceOwner } from '../services/auth-service.js';
|
|
20
|
+
import { getAccessibleRepos, hasRepoAccess, canWriteRepo, isFolderAdminOrOwner, getAccessibleEntityIds, filterWikilinks, } from '../services/access-control.js';
|
|
21
|
+
import { assetDeleteDenial } from '../services/asset-delete-authz.js';
|
|
22
|
+
import { isArchivedRepo } from '../core/types.js';
|
|
20
23
|
import { createSharedFolder, FolderCreationError } from '../services/folder-creation.js';
|
|
21
24
|
import { validateFolderPath } from '../core/folder-paths.js';
|
|
22
25
|
import { existsSync } from 'fs';
|
|
@@ -25,8 +28,8 @@ import { isCuratedEntityType } from '../core/entity-types-catalog.js';
|
|
|
25
28
|
import { describeZodObject } from '../core/schema-registry.js';
|
|
26
29
|
import { getAgentAllowedEntityTypes } from '../agent/prompts/entity-types-section.js';
|
|
27
30
|
import * as commentVirtualEntity from '../services/comment-virtual-entity.js';
|
|
28
|
-
import { buildActor, prepareCreatePayload, prepareUpdatePayload, diagnosedUpdate, DroppedAssetError, assertEntityRefs, applyPatchEntity,
|
|
29
|
-
import {
|
|
31
|
+
import { buildActor, prepareCreatePayload, prepareUpdatePayload, diagnosedUpdate, DroppedAssetError, assertEntityRefs, applyPatchEntity, } from '../services/entity-mutations.js';
|
|
32
|
+
import { ArtifactStore, ArtifactWriteError } from '../services/artifact-store.js';
|
|
30
33
|
import { hashArtifactBody } from '../agent/tools/artifact-tools.js';
|
|
31
34
|
import { BridgeError, validateUserHandleInPayload, assertCanChangeUserHandle } from '../services/user-person-bridge.js';
|
|
32
35
|
import { extractAssetEmbeds, stripFrontmatter, assetEmbed } from '../core/embeds.js';
|
|
@@ -153,48 +156,26 @@ const DEFAULT_FOLDER_BY_TYPE = {
|
|
|
153
156
|
function relPathOf(absPath, repoRoot) {
|
|
154
157
|
return absPath.startsWith(repoRoot + '/') ? absPath.slice(repoRoot.length + 1) : absPath;
|
|
155
158
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
* entrypoint has no content). Parse failures count as NON-blank — never treat
|
|
175
|
-
* unknown content as safely overwritable. */
|
|
176
|
-
function artifactBodyIsBlank(body) {
|
|
177
|
-
try {
|
|
178
|
-
const validation = validateArtifactBundle(JSON.parse(body));
|
|
179
|
-
if (!validation.ok || !validation.bundle)
|
|
180
|
-
return false;
|
|
181
|
-
return bundleEntrypointBlank(validation.bundle);
|
|
182
|
-
}
|
|
183
|
-
catch {
|
|
184
|
-
return false;
|
|
159
|
+
/**
|
|
160
|
+
* Robustly resolve an entity from a search/list result so any hit re-opens
|
|
161
|
+
* without the caller guessing the physical type: explicit `path` wins; else try
|
|
162
|
+
* the supplied `entity_type`; on a miss (wrong/legacy type — e.g. an `event`
|
|
163
|
+
* whose `type` field is "meeting") fall back to id-only resolution. Shared by
|
|
164
|
+
* get_entity and delete_entity so the resolution can't drift between read and
|
|
165
|
+
* delete. Throws when nothing resolves (caller wraps via fail()).
|
|
166
|
+
*/
|
|
167
|
+
function resolveEntityRef(graph, ref) {
|
|
168
|
+
if (ref.path)
|
|
169
|
+
return graph.getByPath(ref.path);
|
|
170
|
+
if (ref.entity_type) {
|
|
171
|
+
try {
|
|
172
|
+
return graph.get(ref.entity_type, ref.entity_id);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return graph.getById(ref.entity_id);
|
|
176
|
+
}
|
|
185
177
|
}
|
|
186
|
-
|
|
187
|
-
function artifactFrontmatter(bundle, options = {}) {
|
|
188
|
-
const data = {
|
|
189
|
-
name: bundle.title,
|
|
190
|
-
kind: bundle.kind,
|
|
191
|
-
viewport_width: bundle.viewport.width,
|
|
192
|
-
viewport_height: bundle.viewport.height,
|
|
193
|
-
viewport_mode: bundle.viewport.mode,
|
|
194
|
-
};
|
|
195
|
-
if (options.includeStatus)
|
|
196
|
-
data.status = 'draft';
|
|
197
|
-
return data;
|
|
178
|
+
return graph.getById(ref.entity_id);
|
|
198
179
|
}
|
|
199
180
|
function parseArtifactBundleFromBody(content, label) {
|
|
200
181
|
let rawBundle;
|
|
@@ -305,7 +286,10 @@ export function registerTools(server, wm, authService, ctx) {
|
|
|
305
286
|
const mcpAllowedCreateTypes = getAgentAllowedEntityTypes(customWorkspaceTypes);
|
|
306
287
|
const mcpAllowedSet = new Set(mcpAllowedCreateTypes);
|
|
307
288
|
// ── list_repos ────────────────────────────────────────────────────────────
|
|
308
|
-
reg('list_repos', { description: 'List folders in the Studiograph workspace that you have access to' },
|
|
289
|
+
reg('list_repos', { description: 'List folders in the Studiograph workspace that you have access to' },
|
|
290
|
+
// Archived folders are hidden from the inventory (parity with the sidebar +
|
|
291
|
+
// agent folder list); they stay reachable by name for unarchive.
|
|
292
|
+
async () => ok(getAccessibleRepos(user, wm, authService).filter(r => !isArchivedRepo(r))));
|
|
309
293
|
// ── create_repo ───────────────────────────────────────────────────────────
|
|
310
294
|
//
|
|
311
295
|
// Creates a new shared folder in the workspace and grants the
|
|
@@ -419,24 +403,8 @@ export function registerTools(server, wm, authService, ctx) {
|
|
|
419
403
|
}
|
|
420
404
|
const graph = wm.getGraph(repo);
|
|
421
405
|
// Resolve robustly so any search/list result re-opens without the
|
|
422
|
-
// caller guessing the physical type
|
|
423
|
-
|
|
424
|
-
// `type` field is "meeting") fall back to id-only resolution.
|
|
425
|
-
let entity;
|
|
426
|
-
if (path) {
|
|
427
|
-
entity = graph.getByPath(path);
|
|
428
|
-
}
|
|
429
|
-
else if (entity_type) {
|
|
430
|
-
try {
|
|
431
|
-
entity = graph.get(entity_type, entity_id);
|
|
432
|
-
}
|
|
433
|
-
catch {
|
|
434
|
-
entity = graph.getById(entity_id);
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
else {
|
|
438
|
-
entity = graph.getById(entity_id);
|
|
439
|
-
}
|
|
406
|
+
// caller guessing the physical type (shared with delete_entity).
|
|
407
|
+
const entity = resolveEntityRef(graph, { entity_type, entity_id, path });
|
|
440
408
|
// Prefer the live editor body when a seeded Y.Doc is open: graph reads
|
|
441
409
|
// disk, which lags the editor by the autosave debounce, so a user's
|
|
442
410
|
// freshly-placed image/asset embed may not be on disk yet. Use the
|
|
@@ -828,117 +796,61 @@ export function registerTools(server, wm, authService, ctx) {
|
|
|
828
796
|
if (base_hash && !entity_id) {
|
|
829
797
|
return fail(new Error('base_hash implies updating an existing artifact, but entity_id is missing — pass the entity_id you read with get_entity, or omit base_hash to create a new artifact.'));
|
|
830
798
|
}
|
|
831
|
-
const validation = validateArtifactBundle(rawBundle);
|
|
832
|
-
if (!validation.ok || !validation.bundle) {
|
|
833
|
-
return fail(new Error(`Invalid artifact bundle: ${validation.errors.join('; ')}`));
|
|
834
|
-
}
|
|
835
|
-
const bundle = validation.bundle;
|
|
836
799
|
const graph = wm.getGraph(repo);
|
|
837
800
|
const actor = buildActor(user);
|
|
838
|
-
const body = `${JSON.stringify(bundle, null, 2)}\n`;
|
|
839
|
-
const createData = artifactFrontmatter(bundle, { includeStatus: true });
|
|
840
|
-
const updateData = artifactFrontmatter(bundle);
|
|
841
801
|
return withAgentActivity(repo, entity_id ? 'editing' : 'creating', async () => {
|
|
842
802
|
try {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
let suffix = 2;
|
|
856
|
-
while (suffix < 1000) {
|
|
857
|
-
try {
|
|
858
|
-
graph.get('artifact', resolvedId);
|
|
859
|
-
resolvedId = `${preferredId}-${suffix++}`;
|
|
860
|
-
}
|
|
861
|
-
catch {
|
|
862
|
-
break;
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
// The caller asserted "this is an edit of the version I read"
|
|
867
|
-
// (base_hash) but the target no longer resolves — deleted, moved, or
|
|
868
|
-
// renamed since the read. Recreating it from a possibly stale copy
|
|
869
|
-
// would silently resurrect deleted content; fail loudly instead.
|
|
870
|
-
if (!existing && entity_id && base_hash) {
|
|
871
|
-
return fail(new Error(`Artifact "${entity_id}" no longer exists in "${repo}" (deleted or moved since you read it). Re-check with get_entity or search_entities before retrying — refusing to recreate it from a possibly stale copy.`));
|
|
872
|
-
}
|
|
873
|
-
if (existing) {
|
|
874
|
-
const liveBody = graph.getLiveBody('artifact', resolvedId) ?? existing.document.content;
|
|
875
|
-
const targetIsBlank = artifactBodyIsBlank(liveBody);
|
|
876
|
-
// Agent-protocol staleness rules (mirror the in-app create_artifact
|
|
877
|
-
// baseHash rules; Phase 4's ArtifactStore unifies both surfaces):
|
|
878
|
-
// 1. Updating an artifact that already has content REQUIRES the
|
|
879
|
-
// artifact_hash from get_entity — an edit not derived from a
|
|
880
|
-
// read would silently drop someone else's changes.
|
|
881
|
-
if (!targetIsBlank && !base_hash) {
|
|
882
|
-
return fail(new Error('This artifact already has content — call get_entity first and pass its artifact_hash as base_hash, so a concurrent change is detected instead of silently overwritten. (Intentionally clearing it requires base_hash plus replace: true.)'));
|
|
883
|
-
}
|
|
884
|
-
// 2. The hash must match the LIVE body (the same source get_entity
|
|
885
|
-
// hashed) — otherwise the edit was derived from an outdated read.
|
|
886
|
-
if (base_hash && base_hash !== hashArtifactBody(liveBody)) {
|
|
887
|
-
return fail(new Error('Artifact changed since you read it (base_hash mismatch). Call get_entity again, re-apply your change to the current source, then retry with the new artifact_hash.'));
|
|
888
|
-
}
|
|
889
|
-
// 3. A blank bundle over a populated artifact is almost always a
|
|
890
|
-
// truncated/failed generation, not an intentional clear.
|
|
891
|
-
if (!targetIsBlank && bundleEntrypointBlank(bundle) && !replace) {
|
|
892
|
-
return fail(new Error('The new bundle is blank but this artifact has content. Pass replace: true only if you intend to clear it.'));
|
|
893
|
-
}
|
|
894
|
-
// Write-time optimistic concurrency (mirrors the in-app rule): the
|
|
895
|
-
// coordinator verifies the DISK body still matches what we read
|
|
896
|
-
// here, so two racing whole-bundle writes can't both land — the
|
|
897
|
-
// loser gets a conflict instead of silently clobbering.
|
|
898
|
-
const options = prepareUpdatePayload({
|
|
899
|
-
entityType: 'artifact',
|
|
900
|
-
entityId: resolvedId,
|
|
901
|
-
data: updateData,
|
|
902
|
-
content: body,
|
|
903
|
-
baseRevision: bodyRevisionHash(existing.document.content),
|
|
904
|
-
}, actor);
|
|
905
|
-
const { entity, frontmatter_changed, content_changed, noop, broadcast } = await diagnosedUpdate(graph, options, { data: updateData, content: body });
|
|
906
|
-
emitEntityChange('updated', repo, entity.entityType, entity.id);
|
|
907
|
-
return ok({
|
|
908
|
-
success: true,
|
|
909
|
-
action: 'updated',
|
|
910
|
-
id: entity.id,
|
|
911
|
-
type: entity.entityType,
|
|
912
|
-
repo,
|
|
913
|
-
path: relPathOf(entity.path, graph.getRepoPath()),
|
|
914
|
-
commit: graph.getGitService()?.getHeadCommit() ?? null,
|
|
915
|
-
frontmatter_changed,
|
|
916
|
-
content_changed,
|
|
917
|
-
noop,
|
|
918
|
-
broadcast,
|
|
919
|
-
});
|
|
920
|
-
}
|
|
921
|
-
const entity = await graph.create(prepareCreatePayload({
|
|
922
|
-
entityType: 'artifact',
|
|
923
|
-
entityId: resolvedId,
|
|
924
|
-
data: createData,
|
|
925
|
-
content: body,
|
|
803
|
+
// The ArtifactStore owns the whole write lifecycle (validate, identity,
|
|
804
|
+
// blank-stub adoption, content-equality idempotency, the OCC rules, the
|
|
805
|
+
// diagnosed write). The MCP surface stays thin: access (above), the
|
|
806
|
+
// response envelope, and the change broadcast.
|
|
807
|
+
const result = await new ArtifactStore(graph).write({
|
|
808
|
+
bundle: rawBundle,
|
|
809
|
+
actor,
|
|
810
|
+
// MCP entity_id is create-or-update-AT-id: a caller picks the id and
|
|
811
|
+
// expects it created if absent (unless base_hash asserts an edit).
|
|
812
|
+
target: entity_id ? { entityId: entity_id, createIfMissing: true } : null,
|
|
813
|
+
baseHash: base_hash,
|
|
814
|
+
replace,
|
|
926
815
|
folderPath: folder_path || undefined,
|
|
927
|
-
}
|
|
928
|
-
|
|
816
|
+
});
|
|
817
|
+
const entity = result.entity;
|
|
818
|
+
// Broadcast only when something actually changed. `noop` covers BOTH the
|
|
819
|
+
// content-equality short-circuit (no write) AND a diagnosedUpdate that
|
|
820
|
+
// ran but changed nothing — neither should emit a phantom change. Matches
|
|
821
|
+
// the chat surface's `!location.noop` gate.
|
|
822
|
+
if (!result.noop)
|
|
823
|
+
emitEntityChange(result.action, repo, entity.entityType, entity.id);
|
|
929
824
|
return ok({
|
|
930
825
|
success: true,
|
|
931
|
-
action:
|
|
932
|
-
id:
|
|
826
|
+
action: result.action,
|
|
827
|
+
id: result.entityId,
|
|
933
828
|
type: entity.entityType,
|
|
934
829
|
repo,
|
|
935
830
|
path: relPathOf(entity.path, graph.getRepoPath()),
|
|
936
831
|
commit: graph.getGitService()?.getHeadCommit() ?? null,
|
|
832
|
+
noop: result.noop,
|
|
833
|
+
...(result.diagnostics ? {
|
|
834
|
+
frontmatter_changed: result.diagnostics.frontmatterChanged,
|
|
835
|
+
content_changed: result.diagnostics.contentChanged,
|
|
836
|
+
broadcast: result.diagnostics.broadcast,
|
|
837
|
+
} : {}),
|
|
937
838
|
});
|
|
938
839
|
}
|
|
939
840
|
catch (err) {
|
|
940
|
-
if (err instanceof
|
|
941
|
-
|
|
841
|
+
if (err instanceof ArtifactWriteError) {
|
|
842
|
+
switch (err.code) {
|
|
843
|
+
case 'needs-base-hash':
|
|
844
|
+
return fail(new Error('This artifact already has content — call get_entity first and pass its artifact_hash as base_hash, so a concurrent change is detected instead of silently overwritten. (Intentionally clearing it requires base_hash plus replace: true.)'));
|
|
845
|
+
case 'stale':
|
|
846
|
+
return fail(new Error('Artifact changed since you read it (base_hash mismatch). Call get_entity again, re-apply your change to the current source, then retry with the new artifact_hash.'));
|
|
847
|
+
case 'blank-overwrite':
|
|
848
|
+
return fail(new Error('The new bundle is blank but this artifact has content. Pass replace: true only if you intend to clear it.'));
|
|
849
|
+
case 'deleted':
|
|
850
|
+
return fail(new Error(`Artifact "${entity_id}" no longer exists in "${repo}" (deleted or moved since you read it). Re-check with get_entity or search_entities before retrying — refusing to recreate it from a possibly stale copy.`));
|
|
851
|
+
default:
|
|
852
|
+
return fail(new Error(err.message));
|
|
853
|
+
}
|
|
942
854
|
}
|
|
943
855
|
return fail(err);
|
|
944
856
|
}
|
|
@@ -1681,6 +1593,200 @@ export function registerTools(server, wm, authService, ctx) {
|
|
|
1681
1593
|
}
|
|
1682
1594
|
});
|
|
1683
1595
|
});
|
|
1596
|
+
// ── delete_entity ─────────────────────────────────────────────────────────
|
|
1597
|
+
//
|
|
1598
|
+
// Delete an entry. Git-committed and recoverable from history
|
|
1599
|
+
// (list_recent_deletions / restore_file). Embedded assets are KEPT — assets
|
|
1600
|
+
// are folder-owned library citizens that entries only reference
|
|
1601
|
+
// (assets-referenced-not-owned P1), so deleting an entry never destroys
|
|
1602
|
+
// binaries. In the workspace_wide Assets store the per-asset uploader/owner
|
|
1603
|
+
// delete gate still applies (D2). Robust resolution mirrors get_entity.
|
|
1604
|
+
const deleteEntitySchema = z.object({
|
|
1605
|
+
repo: z.string().describe('Folder name'),
|
|
1606
|
+
entity_type: z.string().optional().describe('Canonical entity type (as returned by search/list `type`). Optional: when omitted or wrong, the entry is resolved by id.'),
|
|
1607
|
+
entity_id: z.string().optional().describe('Entity ID (kebab-case slug). Provide entity_id or path.'),
|
|
1608
|
+
path: z.string().optional().describe('Repo-relative file path from a search/list result. When given, resolves directly and ignores entity_type.'),
|
|
1609
|
+
});
|
|
1610
|
+
reg('delete_entity', {
|
|
1611
|
+
description: 'Delete an entry. Git-committed and recoverable from history via list_recent_deletions / restore_file. ' +
|
|
1612
|
+
'Embedded assets are NOT deleted — assets are folder-owned and only referenced by entries. ' +
|
|
1613
|
+
'Robust resolution: pass type+id or path exactly as returned by search_entities/list_entities; a wrong or omitted type still resolves by id. ' +
|
|
1614
|
+
'Destructive — only call on explicit user request.',
|
|
1615
|
+
inputSchema: deleteEntitySchema,
|
|
1616
|
+
}, async (args) => {
|
|
1617
|
+
const { repo, entity_type, entity_id, path } = deleteEntitySchema.parse(args);
|
|
1618
|
+
if (!hasRepoAccess(user, repo, wm, authService))
|
|
1619
|
+
return notFound(repo);
|
|
1620
|
+
if (!canWriteRepo(user, repo, wm, authService)) {
|
|
1621
|
+
return fail(new Error(`You do not have write access to folder "${repo}"`));
|
|
1622
|
+
}
|
|
1623
|
+
if (!entity_id && !path)
|
|
1624
|
+
return fail(new Error('delete_entity requires entity_id or path'));
|
|
1625
|
+
const actor = buildActor(user);
|
|
1626
|
+
return withAgentActivity(repo, 'deleting', async () => {
|
|
1627
|
+
try {
|
|
1628
|
+
const graph = wm.getGraph(repo);
|
|
1629
|
+
// Resolve robustly (shared with get_entity): explicit path wins; else
|
|
1630
|
+
// try the supplied type; on a miss fall back to id-only resolution.
|
|
1631
|
+
const entity = resolveEntityRef(graph, { entity_type, entity_id, path });
|
|
1632
|
+
const resolvedType = entity.entityType;
|
|
1633
|
+
const resolvedId = entity.id;
|
|
1634
|
+
// Workspace-Assets store gate (D2): in a workspace_wide folder only the
|
|
1635
|
+
// uploader or a workspace owner may delete — folder-write is not enough.
|
|
1636
|
+
const denial = assetDeleteDenial(wm.getAssetIndex(), wm.getRepoConfig(repo), user, repo, resolvedType, resolvedId);
|
|
1637
|
+
if (denial)
|
|
1638
|
+
return fail(new Error(denial));
|
|
1639
|
+
// Soft bridge-awareness: deleting a person linked to an auth account
|
|
1640
|
+
// orphans that user. Don't block — surface a warning so the agent can
|
|
1641
|
+
// tell the user (the Profile panel catches the orphan after).
|
|
1642
|
+
let bridgeWarning;
|
|
1643
|
+
if (resolvedType === 'person') {
|
|
1644
|
+
const handle = entity.data?.user_handle;
|
|
1645
|
+
if (typeof handle === 'string' && handle.length > 0) {
|
|
1646
|
+
bridgeWarning = `This record was linked to the "${handle}" account. Deleting it unlinks that user — their "My Tasks", @mentions, and assignments stop resolving until they link a new record.`;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
await graph.delete(resolvedType, resolvedId, undefined, actor);
|
|
1650
|
+
emitEntityChange('deleted', repo, resolvedType, resolvedId);
|
|
1651
|
+
return ok({
|
|
1652
|
+
success: true,
|
|
1653
|
+
repo,
|
|
1654
|
+
type: resolvedType,
|
|
1655
|
+
id: resolvedId,
|
|
1656
|
+
...(bridgeWarning ? { warning: bridgeWarning } : {}),
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
catch (err) {
|
|
1660
|
+
return fail(err);
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
});
|
|
1664
|
+
// ── delete_subfolder ──────────────────────────────────────────────────────
|
|
1665
|
+
//
|
|
1666
|
+
// Delete a subfolder inside a folder/collection. Empty → delete; non-empty +
|
|
1667
|
+
// recursive:false → refuse with a blast-radius payload; recursive:true →
|
|
1668
|
+
// delete the folder + its entries. Deleted entries are git-recoverable; their
|
|
1669
|
+
// referenced assets are kept (folder-owned). Distinct from archive_repo, which
|
|
1670
|
+
// reversibly hides an entire top-level folder.
|
|
1671
|
+
const deleteSubfolderSchema = z.object({
|
|
1672
|
+
repo: z.string().describe('Folder name (collection) containing the subfolder'),
|
|
1673
|
+
path: z.string().describe('Repo-relative POSIX path of the subfolder to delete (e.g. "Clients/Acme")'),
|
|
1674
|
+
recursive: z.boolean().optional().describe('Delete the folder AND every entry/subfolder inside it. Defaults to false: a non-empty folder is REFUSED with a blast-radius payload (entriesDeleted, subfoldersDeleted) so you can confirm with the user first. NEVER pass true on the first attempt.'),
|
|
1675
|
+
});
|
|
1676
|
+
reg('delete_subfolder', {
|
|
1677
|
+
description: 'Delete a subfolder inside a folder. Empty subfolders delete immediately. A non-empty subfolder is REFUSED unless recursive:true — the refusal returns the blast radius (entries + subfolders that would be removed) so you can confirm with the user before re-invoking. Deleted ENTRIES are git-recoverable; their referenced assets are kept. To remove a whole top-level folder, prefer archive_repo (reversible) — permanent folder deletion is web/human-only.',
|
|
1678
|
+
inputSchema: deleteSubfolderSchema,
|
|
1679
|
+
}, async (args) => {
|
|
1680
|
+
const { repo, path, recursive } = deleteSubfolderSchema.parse(args);
|
|
1681
|
+
if (!hasRepoAccess(user, repo, wm, authService))
|
|
1682
|
+
return notFound(repo);
|
|
1683
|
+
if (!canWriteRepo(user, repo, wm, authService)) {
|
|
1684
|
+
return fail(new Error(`You do not have write access to folder "${repo}"`));
|
|
1685
|
+
}
|
|
1686
|
+
// Workspace-Assets store gate (mirror the web route): a recursive delete in
|
|
1687
|
+
// a workspace_wide store destroys every asset under the folder, bypassing
|
|
1688
|
+
// the per-asset uploader/owner rule — restrict it to a workspace owner.
|
|
1689
|
+
if (recursive === true && wm.getRepoConfig(repo)?.workspace_wide && !isWorkspaceOwner(user.role)) {
|
|
1690
|
+
return fail(new Error('Only a workspace owner can recursively delete folders in the shared Assets store.'));
|
|
1691
|
+
}
|
|
1692
|
+
const actor = buildActor(user);
|
|
1693
|
+
return withAgentActivity(repo, 'deleting', async () => {
|
|
1694
|
+
try {
|
|
1695
|
+
// Validate + normalize the path BEFORE the destructive call (don't
|
|
1696
|
+
// rely on deleteFolder's internal check as the only guard against
|
|
1697
|
+
// traversal/root/malformed paths). validateFolderPath throws
|
|
1698
|
+
// InvalidFolderPathError for traversal and returns '' for the repo
|
|
1699
|
+
// root, which deleteFolder also rejects. The throw lands in the catch
|
|
1700
|
+
// below and surfaces as a fail() result. Pass the normalized path.
|
|
1701
|
+
const normPath = validateFolderPath(path);
|
|
1702
|
+
const graph = wm.getGraph(repo);
|
|
1703
|
+
const result = await graph.deleteFolder(normPath, { recursive: recursive === true, user: actor });
|
|
1704
|
+
emitEntityChange('deleted', repo, '_folder', normPath);
|
|
1705
|
+
return ok({
|
|
1706
|
+
success: true,
|
|
1707
|
+
repo,
|
|
1708
|
+
path: normPath,
|
|
1709
|
+
entriesDeleted: result.entriesDeleted,
|
|
1710
|
+
subfoldersDeleted: result.subfoldersDeleted,
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
catch (err) {
|
|
1714
|
+
// Surface the blast radius so the caller can confirm before retrying
|
|
1715
|
+
// recursively (structured, like the web route's 409 body).
|
|
1716
|
+
if (err?.code === 'FOLDER_NOT_EMPTY' && err.blastRadius) {
|
|
1717
|
+
return {
|
|
1718
|
+
content: [{
|
|
1719
|
+
type: 'text',
|
|
1720
|
+
text: JSON.stringify({
|
|
1721
|
+
error: err.message,
|
|
1722
|
+
code: 'FOLDER_NOT_EMPTY',
|
|
1723
|
+
blast_radius: err.blastRadius,
|
|
1724
|
+
}, null, 2),
|
|
1725
|
+
}],
|
|
1726
|
+
isError: true,
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
return fail(err);
|
|
1730
|
+
}
|
|
1731
|
+
});
|
|
1732
|
+
});
|
|
1733
|
+
// ── archive_repo / unarchive_repo ─────────────────────────────────────────
|
|
1734
|
+
//
|
|
1735
|
+
// Archive is a reversible, lossless, identity-preserving repo-level hide (a
|
|
1736
|
+
// flag on the config, NOT a rename). An archived folder drops out of the
|
|
1737
|
+
// sidebar, search, graph, and the agent inventory but keeps its entries,
|
|
1738
|
+
// wikilinks, and assets. Agents/MCP may archive but may NEVER permanently
|
|
1739
|
+
// delete a repo — permanent deletion stays a deliberate human action in the
|
|
1740
|
+
// web UI. Folder admin or workspace owner only.
|
|
1741
|
+
const archiveRepoSchema = z.object({
|
|
1742
|
+
repo: z.string().describe('Folder name to archive'),
|
|
1743
|
+
});
|
|
1744
|
+
reg('archive_repo', {
|
|
1745
|
+
description: 'Archive a folder — a reversible, lossless hide. The folder drops out of the sidebar, search, graph, and the agent folder inventory, but nothing is deleted (entries, wikilinks, and assets are preserved) and it can be restored with unarchive_repo. Use this instead of deleting a folder: permanent folder deletion is a human-only action in the web UI and is never available to agents. Requires folder admin or workspace owner.',
|
|
1746
|
+
inputSchema: archiveRepoSchema,
|
|
1747
|
+
}, async (args) => {
|
|
1748
|
+
const { repo } = archiveRepoSchema.parse(args);
|
|
1749
|
+
if (!hasRepoAccess(user, repo, wm, authService))
|
|
1750
|
+
return notFound(repo);
|
|
1751
|
+
if (!isFolderAdminOrOwner(user, repo, wm, authService)) {
|
|
1752
|
+
return fail(new Error(`Archiving folder "${repo}" requires folder admin or workspace owner.`));
|
|
1753
|
+
}
|
|
1754
|
+
try {
|
|
1755
|
+
const updated = await wm.setRepoArchived(repo, true, user.handle);
|
|
1756
|
+
return ok({
|
|
1757
|
+
success: true,
|
|
1758
|
+
repo,
|
|
1759
|
+
archived_at: updated.archived_at ?? null,
|
|
1760
|
+
message: `Archived folder "${repo}". It's hidden from the workspace but fully recoverable — call unarchive_repo to restore it.`,
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
catch (err) {
|
|
1764
|
+
return fail(err);
|
|
1765
|
+
}
|
|
1766
|
+
});
|
|
1767
|
+
reg('unarchive_repo', {
|
|
1768
|
+
description: 'Restore a previously archived folder so it reappears in the sidebar, search, graph, and the agent folder inventory. Requires folder admin or workspace owner.',
|
|
1769
|
+
inputSchema: archiveRepoSchema,
|
|
1770
|
+
}, async (args) => {
|
|
1771
|
+
const { repo } = archiveRepoSchema.parse(args);
|
|
1772
|
+
if (!hasRepoAccess(user, repo, wm, authService))
|
|
1773
|
+
return notFound(repo);
|
|
1774
|
+
if (!isFolderAdminOrOwner(user, repo, wm, authService)) {
|
|
1775
|
+
return fail(new Error(`Unarchiving folder "${repo}" requires folder admin or workspace owner.`));
|
|
1776
|
+
}
|
|
1777
|
+
try {
|
|
1778
|
+
const updated = await wm.setRepoArchived(repo, false, user.handle);
|
|
1779
|
+
return ok({
|
|
1780
|
+
success: true,
|
|
1781
|
+
repo,
|
|
1782
|
+
archived: isArchivedRepo(updated),
|
|
1783
|
+
message: `Restored folder "${repo}".`,
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
catch (err) {
|
|
1787
|
+
return fail(err);
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1684
1790
|
// ── validate_wikilinks ────────────────────────────────────────────────────
|
|
1685
1791
|
const validateWikilinksSchema = z.object({
|
|
1686
1792
|
repo: z.string().describe('Folder name'),
|