Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/PATH_SLUGIFICATION_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ function slugWithIdSuffix(title: string, id: string): string {
| Function | Change |
|---|---|
| `linearIssuePath(issueId, title?)` | When `title` provided: `/linear/issues/<slug>.json` |
| `linearCommentPath(issueId, commentId)` | No change — comments have no title |
| `linearCommentPath(commentId, humanReadable?)` | Directory record: `/linear/comments/<name>__<id>/meta.json` |
| `linearMetadataPath` | No change — workspace-level |

**Callers to update:** wherever issues are ingested, pass the issue `title` into the path mapper.
Expand Down Expand Up @@ -155,4 +155,4 @@ function slugWithIdSuffix(title: string, id: string): string {

## Out of Scope

- **The "this model does not support image input" error** — this comes from an external AI provider (OpenAI Codex), not from sync code. It fires when the model reads content containing markdown image links. Fix: filter image blocks from content before sending to models that don't support images, or configure the model to allow image URLs.
- **The "this model does not support image input" error** — this comes from an external AI provider (OpenAI Codex), not from sync code. It fires when the model reads content containing markdown image links. Fix: filter image blocks from content before sending to models that don't support images, or configure the model to allow image URLs.
107 changes: 107 additions & 0 deletions docs/architecture/writeback-resource-patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Writeback Resource Patterns

Conventions adapter authors must follow when choosing the mount path shape for
a provider record. These patterns exist because relayfile workspaces are
materialized onto POSIX filesystems by the mount daemon — path shapes that are
legal in a virtual key/value namespace can be impossible to mirror on disk.

## Directory records: never emit a flat leaf where children can nest

### The collision

A record emitted as a flat leaf file

```
X/<id>.json
```

collides the moment any child resource of that record nests under the same
stem:

```
X/<id>/reactions/...
X/<id>/replies/...
```

`<id>.json` and `<id>/` are distinct keys in the virtual filesystem, but on a
POSIX mount one name cannot be both a file and a directory. The mirror fails
every sync cycle with `mkdir .../X/<id>.json: not a directory`, never completes
bootstrap, and the teardown writeback flush hangs. This is not hypothetical: it
wedged Slack mounts when thread replies were flat files
(`threads/<ts>/replies/<replyTs>.json`) while reply reactions nested under
`replies/<replyTs>/reactions/...` (see commits `dea03fc` and `f5ca1ce`,
PR #162).

### The pattern

Emit every record that has — or could plausibly grow — child resources as a
**directory record**: the stem is a directory keyed by the stable provider id,
and the canonical payload lives in a well-known file inside it:

```
X/<id>/meta.json ← canonical record
X/<id>/reactions/... ← children are siblings of meta.json
X/<id>/replies/...
```

Collision is then impossible by construction: the record and its children share
one directory.

"Could plausibly grow" should be read generously. If the provider's API exposes
any per-record child collection (reactions, nested replies, attachments,
statuses, history), assume a future adapter version will materialize it. The
cost of a directory record up front is one extra path segment; the cost of
migrating later is legacy-path compatibility shims forever.

Current directory-record adopters:

| Adapter | Record | Canonical path |
| --- | --- | --- |
| slack | channel message | `/slack/channels/<c>/messages/<ts>/meta.json` |
| slack | thread reply | `/slack/channels/<c>/threads/<ts>/replies/<ts>/meta.json` |
| slack | DM thread reply | `/slack/users/<u>/messages/<ts>/replies/<ts>/meta.json` |
| github | issue / pull request | `/github/repos/<o>/<r>/issues/<n>__<slug>/meta.json` |
| github | issue comment | `/github/repos/<o>/<r>/issues/<n>__<slug>/comments/<id>/meta.json` |
| linear | comment | `/linear/comments/<name>__<id>/meta.json` |

Leaf records with genuinely no child surface (index rows, alias lookups like
`by-id/<id>.json`, append-only event captures) may stay flat files.

## Migrating a flat leaf to a directory record

When an existing adapter shipped the flat shape, the migration must keep
pre-migration mirrors readable and routable:

1. **Writer**: change the canonical path helper to `<id>/meta.json`. Document
why in the helper's doc comment.
2. **Legacy helper**: keep the old flat path available as
`<thing>LegacyPath(...)`, marked `@deprecated`, for back-compat reads (and
tombstone deletes of legacy mirrors).
3. **Read candidates**: expose `<thing>ReadCandidatePaths(...)` returning
`[currentPath, legacyPath]` so readers resolve records mirrored by either
adapter generation.
4. **Parsers/routers**: every regex or matcher that recognizes the record path
must accept both `<id>/meta.json` and the legacy `<id>.json` (see the Slack
`thread.ts` reply-listing regex and the GitHub
`ISSUE_COMMENT_WRITEBACK_PATH`).
5. **Writeback resource config**: if the record is a writeback target, the
resource `pathPattern` must match the `/meta.json` form and the `idPattern`
must accept the literal `meta` stem (the handler re-derives the real id from
the full path). Slack's `messages` resource and GitHub's `issue-comments`
resource are the references. These live in
`scripts/writeback-discovery-data.mjs` / `writeback-discovery-normalizer.mjs`
and are regenerated into each adapter's `src/resources.ts` and discovery
`.adapter.md` by `scripts/generate-writeback-discovery.mjs`.
6. **Docs**: update the adapter's `layout-prompt.ts` (the mounted `LAYOUT.md`)
and discovery read-path docs so agents construct the new shape.
7. **Tests**: add a regression test pinning (a) the directory-record path, (b)
the nesting invariant for a child path, and (c) the read-candidate fallback
order. See `packages/slack/src/__tests__/path-mapper-v2.test.ts`
(`threadReplyPath is a directory record ...`),
`packages/github/src/__tests__/path-mapper.test.ts`
(`githubIssueCommentPath`), and
`packages/linear/src/__tests__/path-mapper.test.ts` (`linearCommentPath`).

Do not represent the migration as a delete of the legacy file unless the
upstream object was actually deleted — pre-migration mirrors keep their flat
files until the record is next written or tombstoned.
23 changes: 23 additions & 0 deletions packages/core/src/runtime/file-native-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ const resources: readonly AdapterResourceConfig[] = [
createExample:
"discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json",
},
{
name: "issue-comments",
path: "/github/repos/{owner}/{repo}/issues/{issueNumber}/comments",
pathPattern:
/^\/github\/repos\/[^/]+\/[^/]+\/issues\/[^/]+\/comments(?:\/[^/]+(?:\.json|\/meta\.json)?)?$/,
idPattern: /^(?:meta|\d+)$/,
schema:
"discovery/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.schema.json",
createExample:
"discovery/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.create.example.json",
},
];

const issueId = "11111111-1111-1111-1111-111111111111";
Expand Down Expand Up @@ -100,6 +111,18 @@ test("classifyWrite maps slugged exact-file resources to patch", () => {
assert.equal(route?.resource.name, "merge");
});

test("classifyWrite maps directory-record meta.json resources to patch", () => {
const route = classifyWrite(
"/github/repos/acme/widgets/issues/42/comments/123/meta.json",
resources
);

assert.equal(route?.kind, "patch");
assert.equal(route?.canonical, true);
assert.equal(route?.id, "meta");
assert.equal(route?.resource.name, "issue-comments");
});

test("classifyWrite ignores temporary and partial writeback filenames", () => {
for (const path of [
"/linear/issues/.tmp.json",
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/writeback-paths/catalog.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@
]
},
"dropbox": {
"cursors": [
{
"path": "/dropbox/cursors",
"params": []
}
],
"files": [
{
"path": "/dropbox/files",
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/writeback-paths/catalog.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ export const WRITEBACK_PATH_CATALOG = {
]
},
"dropbox": {
"cursors": [
{
"path": "/dropbox/cursors",
"params": []
}
],
"files": [
{
"path": "/dropbox/files",
Expand Down
45 changes: 45 additions & 0 deletions packages/dropbox/discovery/dropbox/.adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ Resources:
| Resource | Schema | Create example | ID pattern | What it does |
|---|---|---|---|---|
| `/dropbox/files/<id>.json` | `/dropbox/files/.schema.json` | `/dropbox/files/.create.example.json` | `^[A-Za-z0-9_.:-]+$` | Uploads a Dropbox file. |
| `/dropbox/folders/<id>.json` | `/dropbox/folders/.schema.json` | `/dropbox/folders/.create.example.json` | `^[A-Za-z0-9_.:-]+$` | Creates or updates Dropbox folder metadata. |
| `/dropbox/shared-folders/<id>.json` | `/dropbox/shared-folders/.schema.json` | `/dropbox/shared-folders/.create.example.json` | `^[A-Za-z0-9_.:-]+$` | Creates or updates Dropbox shared folder metadata. |
| `/dropbox/shared-links/<id>.json` | `/dropbox/shared-links/.schema.json` | `/dropbox/shared-links/.create.example.json` | `^[A-Za-z0-9_.:-]+$` | Creates or updates Dropbox shared link metadata. |
| `/dropbox/cursors/<id>.json` | `/dropbox/cursors/.schema.json` | `/dropbox/cursors/.create.example.json` | `^[A-Za-z0-9_.:-]+$` | Stores a list_folder cursor. |

## Operations
Expand All @@ -24,6 +27,9 @@ Resources:

## ID Patterns
- `/dropbox/files/<id>.json`: `^[A-Za-z0-9_.:-]+$`. Filenames that do not match this pattern are treated as create drafts.
- `/dropbox/folders/<id>.json`: `^[A-Za-z0-9_.:-]+$`. Filenames that do not match this pattern are treated as create drafts.
- `/dropbox/shared-folders/<id>.json`: `^[A-Za-z0-9_.:-]+$`. Filenames that do not match this pattern are treated as create drafts.
- `/dropbox/shared-links/<id>.json`: `^[A-Za-z0-9_.:-]+$`. Filenames that do not match this pattern are treated as create drafts.
- `/dropbox/cursors/<id>.json`: `^[A-Za-z0-9_.:-]+$`. Filenames that do not match this pattern are treated as create drafts.

## Write field contracts
Expand All @@ -42,6 +48,45 @@ Fields:
- `contentBase64` (optional, string) - Base64 content.
- `mode` (optional, string) - Upload mode.

### Create Dropbox folder

Resource: `/dropbox/folders/<id>.json`
Schema: `/dropbox/folders/.schema.json`
Create example: `/dropbox/folders/.create.example.json`
Required fields: `path_display`.
Optional fields: `name`.

Fields:

- `path_display` (required, string) - Dropbox display path.
- `name` (optional, string) - Folder name.

### Create Dropbox shared folder marker

Resource: `/dropbox/shared-folders/<id>.json`
Schema: `/dropbox/shared-folders/.schema.json`
Create example: `/dropbox/shared-folders/.create.example.json`
Required fields: `id`.
Optional fields: `name`.

Fields:

- `id` (required, string) - Dropbox shared folder id.
- `name` (optional, string) - Shared folder name.

### Create Dropbox shared link marker

Resource: `/dropbox/shared-links/<id>.json`
Schema: `/dropbox/shared-links/.schema.json`
Create example: `/dropbox/shared-links/.create.example.json`
Required fields: `url`.
Optional fields: `name`.

Fields:

- `url` (required, string) - Dropbox shared link URL.
- `name` (optional, string) - Shared link name.

### Create Dropbox cursor

Resource: `/dropbox/cursors/<id>.json`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
{
"path_display": "/Team/Docs",
"name": "Docs"
"path_display": "/Team"
}
70 changes: 63 additions & 7 deletions packages/dropbox/discovery/dropbox/folders/.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,78 @@
"properties": {
"path_display": {
"type": "string",
"description": "Dropbox display path for the folder."
"description": "Dropbox display path."
},
"name": {
"type": "string",
"description": "Folder name."
},
"path_lower": {
"id": {
"type": "string",
"description": "Normalized lower-case folder path.",
"description": "Provider canonical record id.",
"readOnly": true
},
"parent_shared_folder_id": {
"createdAt": {
"type": "string",
"description": "Parent shared-folder id when this folder is in a shared mount."
"format": "date-time",
"description": "Provider creation timestamp.",
"readOnly": true
},
"updatedAt": {
"type": "string",
"format": "date-time",
"description": "Provider last update timestamp.",
"readOnly": true
},
"url": {
"type": "string",
"format": "uri",
"description": "Provider URL for the record.",
"readOnly": true
},
"identifier": {
"type": "string",
"description": "Provider human-readable identifier or key.",
"readOnly": true
},
"provider": {
"type": "string",
"description": "Relayfile provider name.",
"readOnly": true
},
"objectType": {
"type": "string",
"description": "Relayfile object type.",
"readOnly": true
},
"objectId": {
"type": "string",
"description": "Relayfile object id.",
"readOnly": true
},
"workspaceId": {
"type": "string",
"description": "Relayfile workspace id.",
"readOnly": true
},
"connectionId": {
"type": "string",
"description": "Relayfile connection id.",
"readOnly": true
},
"_webhook": {
"type": "object",
"description": "Provider webhook metadata captured during sync.",
"readOnly": true,
"additionalProperties": true
},
"_connection": {
"type": "object",
"description": "Relayfile connection metadata captured during sync.",
"readOnly": true,
"additionalProperties": true
}
},
"additionalProperties": true,
"description": "Full Dropbox folder metadata schema for Relayfile discovery."
"additionalProperties": false,
"description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents."
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
{
"shared_folder_id": "845281924",
"shared_folder_name": "Finance Shared"
"id": "845281924"
}
Loading
Loading